Repository: mit-ll-responsible-ai/hydra-zen

Issue:
Eliminating restrictions on  `builds(..., zen_partial=<...>, builds_bases=(<...>))`
There are currently two restrictions that hydra-zen imposes on `builds(..., zen_partial=<...>, builds_bases=(<...>))`:

## 1. Inheriting from a partial-builds parent

Inheriting from a parent where `zen_partial=True` requires that all children explicitly specify `zen_partial=True`. 

```python
parent_with_partial = builds(int, zen_partial=True)

child = builds(int, builds_bases=(parent_with_partial,)) # Not allowed
child = builds(int, zen_partial=False, builds_bases=(parent_with_partial,)) # Not allowed

child = builds(int, zen_partial=True, builds_bases=(parent_with_partial,)) # Okay; zen_partial=True for child
```

This restriction is due to the fact that the default argument for `zen_partial` is `False`. Thus there is presently no way for us to distinguish between a user leaving `zen_partial` unspecified and explicitly specifying that they want `zen_partial=False` for the child.

### Solution

We will update the default of `zen_partial` to be `None` so that we can distinguish between `zen_partial` being left un-specified and it being set to `False` explicitly. With this change in place, the following behaviors will manifest:

```python
parent_with_partial = builds(int, zen_partial=True)

child = builds(int, builds_bases=(parent_with_partial,)) # zen_partial=True for child (via inheritance)

child = builds(int, zen_partial=None, builds_bases=(parent_with_partial,)) # zen_partial=True for child (via inheritance)

child = builds(int, zen_partial=False, builds_bases=(parent_with_partial,)) # zen_partial=False for child

child = builds(int, zen_partial=True, builds_bases=(parent_with_partial,)) # zen_partial=True for child
```

## 2. Mixing `_partial_=True` with zen-processing features

Hydra's added support for partial instantiation made possible the formation of configs like:

```
_target_: hydra_zen.funcs.zen_processing
_partial_: true
_zen_target: SomeTarget
```

where instantiating this config will produce:

```
functools.partial(<function zen_processing at 0x0000024DA072FD30>, _zen_target=SomeTarget)
```

this is not desirable as the `.func` attribute of this partial'd object should return `SomeTarget`, it will instead return `hydra_zen.funcs.zen_processing`

Even more insidious is the case where we have:

```
_target_: hydra_zen.funcs.zen_processing
_partial_: true
_zen_target: SomeTarget
_zen_partial: true
```

Here, instantiating this config will produce:

``` python
>>> out = instantiate(conf)
>>> out
functools.partial(<function zen_processing at 0x0000024DA072FD30>, _zen_target=SomeTarget, zen_partial=True)
>>> out()  # uh oh!
functools.partial(SomeTarget)
>>> out()()  # it was double partial'd!
SomeTarget
```

I.e. the presence of `_partial_=true` and `_zen_partial=true` means that the target will be *double partial'd*. For these reasons hydra-zen currently prohibits inheriting from a parent that uses zen-processing features into a child that is: a partial-builds and does not itself use zen-processing features.

Specifically this leads to the following restrictions:

```python
P = builds(int, zen_partial=True)  # has _partial_ = True
builds(int, zen_partial=true_or_false, builds_bases=(P,), zen_meta=dict(a=1))  # Not allowed
```

This is quite unintuitive for users, and leads to issues like #288  where a natural inheritance hierarchy with a partial'd base gets blocked.

### Solution

We can detect when a child is both inheriting `_partial_=True` and is using zen_processing, and we can overwrite `_partial_=False` and `_zen_partial=True` so that only `zen_processing` is responsible for performing the partial instantiation.

Thus we would have:

```python
P = builds(int, zen_partial=True)  # has _partial_ = True
builds(int, zen_partial=true_or_false, builds_bases=(P,), zen_meta=dict(a=1))  # has _partial_ = False, zen_partial=True
```

### Some sharp edges:

If we end up with configs like:

```
_target_: hydra_zen.funcs.zen_processing
_zen_target: builtins.int
_partial_: false
_zen_partial: true
```

users may coerce their builds into being partial-builds by checking for / setting `_partial_=True`. This would be a mistake if `_zen_partial=True` -- it can lead to the "double partial" scenario laid out above. There are a few things that we might do to reduce the risks of this:

- Provide a public `is_partial_builds` function that will check both `_partial_` and `zen_partial` for users. This could either warn or raise in the case when both are set to True
- Provide a `validate_partial_builds` function that will raise when it sees bad combinations of `_partial_` and zen-processing 

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d1cc747b..301ebf15 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,6 @@ repos:
     rev: 22.6.0
     hooks:
     - id: black
-      language_version: python3
 -   repo: https://gitlab.com/pycqa/flake8
     rev: 3.9.2
     hooks:
diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst
index 9c04e6dd..941f9b39 100644
--- a/docs/source/api_reference.rst
+++ b/docs/source/api_reference.rst
@@ -74,6 +74,8 @@ Utilities
 
 
    make_custom_builds_fn
+   is_partial_builds
+   uses_zen_processing
    ZenField
 
 
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 3dfdc220..2fcd41b4 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -29,7 +29,8 @@ hydra-zen :ref:`already provides support for these <additional-types>`, but this
 Improvements
 ------------
 - The :ref:`automatic type refinement <type-support>` performed by :func:`~hydra_zen.builds` now has enhanced support for ``typing.Annotated``, ``typing.NewType``, and ``typing.TypeVarTuple``. (See :pull:`283`)
-
+- :func:`~hydra_zen.builds` no longer has restrictions on inheritance patterns involving `PartialBuilds`-type configs. (See :pull:`290`)
+- Two new utility functions were added to the public API: :func:`~hydra_zen.is_partial_builds` and :func:`~hydra_zen.uses_zen_processing`
 
 Bug Fixes
 ---------
diff --git a/docs/source/generated/hydra_zen.is_partial_builds.rst b/docs/source/generated/hydra_zen.is_partial_builds.rst
new file mode 100644
index 00000000..d2149d29
--- /dev/null
+++ b/docs/source/generated/hydra_zen.is_partial_builds.rst
@@ -0,0 +1,6 @@
+﻿hydra\_zen.is\_partial\_builds
+==============================
+
+.. currentmodule:: hydra_zen
+
+.. autofunction:: is_partial_builds
\ No newline at end of file
diff --git a/docs/source/generated/hydra_zen.uses_zen_processing.rst b/docs/source/generated/hydra_zen.uses_zen_processing.rst
new file mode 100644
index 00000000..0fd19397
--- /dev/null
+++ b/docs/source/generated/hydra_zen.uses_zen_processing.rst
@@ -0,0 +1,6 @@
+﻿hydra\_zen.uses\_zen\_processing
+================================
+
+.. currentmodule:: hydra_zen
+
+.. autofunction:: uses_zen_processing
\ No newline at end of file
diff --git a/src/hydra_zen/__init__.py b/src/hydra_zen/__init__.py
index 00d6e166..ed0c7888 100644
--- a/src/hydra_zen/__init__.py
+++ b/src/hydra_zen/__init__.py
@@ -20,6 +20,7 @@ from .structured_configs import (
     mutable_value,
 )
 from .structured_configs._implementations import get_target
+from .structured_configs._type_guards import is_partial_builds, uses_zen_processing
 
 __all__ = [
     "builds",
@@ -36,6 +37,8 @@ __all__ = [
     "ZenField",
     "make_custom_builds_fn",
     "launch",
+    "is_partial_builds",
+    "uses_zen_processing",
 ]
 
 __version__ = get_versions()["version"]
diff --git a/src/hydra_zen/structured_configs/_globals.py b/src/hydra_zen/structured_configs/_globals.py
index 67732f6c..48ad9a5b 100644
--- a/src/hydra_zen/structured_configs/_globals.py
+++ b/src/hydra_zen/structured_configs/_globals.py
@@ -35,7 +35,7 @@ del _names
 ZEN_PROCESSING_LOCATION: Final[str] = _utils.get_obj_path(zen_processing)
 GET_OBJ_LOCATION: Final[str] = _utils.get_obj_path(get_obj)
 ZEN_TARGET_FIELD_NAME: Final[str] = "_zen_target"
-ZEN_PARTIAL_TARGET_FIELD_NAME: Final[str] = "_zen_partial"
+ZEN_PARTIAL_FIELD_NAME: Final[str] = "_zen_partial"
 META_FIELD_NAME: Final[str] = "_zen_exclude"
 ZEN_WRAPPERS_FIELD_NAME: Final[str] = "_zen_wrappers"
 JUST_FIELD_NAME: Final[str] = "path"
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index b792fdf6..13e74a62 100644
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -75,7 +75,7 @@ from ._globals import (
     POS_ARG_FIELD_NAME,
     RECURSIVE_FIELD_NAME,
     TARGET_FIELD_NAME,
-    ZEN_PARTIAL_TARGET_FIELD_NAME,
+    ZEN_PARTIAL_FIELD_NAME,
     ZEN_PROCESSING_LOCATION,
     ZEN_TARGET_FIELD_NAME,
     ZEN_WRAPPERS_FIELD_NAME,
@@ -185,7 +185,7 @@ def __dataclass_transform__(
 def hydrated_dataclass(
     target: Callable[..., Any],
     *pos_args: SupportedPrimitive,
-    zen_partial: bool = False,
+    zen_partial: Optional[bool] = None,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
     zen_meta: Optional[Mapping[str, Any]] = None,
     populate_full_signature: bool = False,
@@ -211,7 +211,7 @@ def hydrated_dataclass(
         Arguments specified positionally are not included in the dataclass' signature and
         are stored as a tuple bound to in the ``_args_`` field.
 
-    zen_partial : bool, optional (default=False)
+    zen_partial : Optional[bool]
         If ``True``, then the resulting config will instantiate as
         ``functools.partial(hydra_target, *pos_args, **kwargs_for_target)`` rather than
         ``hydra_target(*pos_args, **kwargs_for_target)``. Thus this enables the
@@ -563,7 +563,7 @@ def sanitized_field(
 def builds(
     __hydra_target: Callable[P, R],
     *,
-    zen_partial: Literal[False] = ...,
+    zen_partial: Literal[False, None] = ...,
     populate_full_signature: Literal[True],
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -582,7 +582,7 @@ def builds(
 def builds(
     __hydra_target: Importable,
     *pos_args: SupportedPrimitive,
-    zen_partial: Literal[False] = ...,
+    zen_partial: Literal[False, None] = ...,
     populate_full_signature: bool = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -622,7 +622,7 @@ def builds(
 def builds(
     __hydra_target: Importable,
     *pos_args: SupportedPrimitive,
-    zen_partial: bool = ...,
+    zen_partial: Optional[bool] = ...,
     populate_full_signature: Literal[False] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -645,7 +645,7 @@ def builds(
 def builds(
     __hydra_target: Union[Callable[P, R], Importable],
     *pos_args: SupportedPrimitive,
-    zen_partial: bool,
+    zen_partial: Optional[bool],
     populate_full_signature: bool = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -666,7 +666,7 @@ def builds(
 
 def builds(
     *pos_args: Union[Importable, Callable[P, R], SupportedPrimitive],
-    zen_partial: bool = False,
+    zen_partial: Optional[bool] = None,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
     zen_meta: Optional[Mapping[str, SupportedPrimitive]] = None,
     populate_full_signature: bool = False,
@@ -682,7 +682,7 @@ def builds(
     Type[PartialBuilds[Importable]],
     Type[BuildsWithSig[Type[R], P]],
 ]:
-    """builds(hydra_target, /, *pos_args, zen_partial=False, zen_wrappers=(), zen_meta=None, populate_full_signature=False, hydra_recursive=None, hydra_convert=None, hydra_defaults=None, frozen=False, dataclass_name=None, builds_bases=(), **kwargs_for_target)
+    """builds(hydra_target, /, *pos_args, zen_partial=None, zen_wrappers=(), zen_meta=None, populate_full_signature=False, hydra_recursive=None, hydra_convert=None, hydra_defaults=None, frozen=False, dataclass_name=None, builds_bases=(), **kwargs_for_target)
 
     Returns a structured config, which describes how to instantiate/call
     ``<hydra_target>`` with both user-specified and auto-populated parameter values.
@@ -714,7 +714,7 @@ def builds(
         ``_zen_`` are reserved to ensure future-compatibility, and thus cannot be
         specified by the user.
 
-    zen_partial : bool, optional (default=False)
+    zen_partial : Optional[bool]
         If ``True``, then the resulting config will instantiate as
         ``functools.partial(<hydra_target>, *pos_args, **kwargs_for_target)``. Thus
         this enables the partial-configuration of objects.
@@ -1061,7 +1061,7 @@ def builds(
             f"`hydra_recursive` must be a boolean type, got {hydra_recursive}"
         )
 
-    if not isinstance(zen_partial, bool):
+    if zen_partial is not None and not isinstance(zen_partial, bool):
         raise TypeError(f"`zen_partial` must be a boolean type, got: {zen_partial}")
 
     if hydra_convert is not None and hydra_convert not in {"none", "partial", "all"}:
@@ -1162,14 +1162,62 @@ def builds(
 
     target_path: Final[str] = _utils.get_obj_path(target)
 
+    # zen_partial behavior:
+    #
+    # If zen_partial is not None: zen_partial dictates if output is PartialBuilds
+    #
+    # If zen_partial is None:
+    #   - closest parent with partial-flag specified determines
+    #     if output is PartialBuilds
+    #   - if no parent, output is Builds
+    #
+    # If _partial_=True is inherited but zen-processing is used
+    #    then set _partial_=False, _zen_partial=zen_partial
+    #
+    base_hydra_partial: Optional[bool] = None  # state of closest parent with _partial_
+    base_zen_partial: Optional[bool] = None  # state of closest parent with _zen_partial
+
+    # reflects state of closest parent that has partial field specified
+    parent_partial: Optional[bool] = None
+
+    for base in builds_bases:
+        _set_this_iteration = False
+        if HYDRA_SUPPORTS_PARTIAL and base_hydra_partial is None:
+            base_hydra_partial = getattr(base, PARTIAL_FIELD_NAME, None)
+            if parent_partial is None:
+                parent_partial = base_hydra_partial
+                _set_this_iteration = True
+
+        if base_zen_partial is None:
+            base_zen_partial = getattr(base, ZEN_PARTIAL_FIELD_NAME, None)
+            if parent_partial is None or (
+                _set_this_iteration and base_zen_partial is not None
+            ):
+                parent_partial = parent_partial or base_zen_partial
+
+        del _set_this_iteration
+
+    if zen_partial is None:
+        # zen_partial is inherited
+        zen_partial = parent_partial
+
+    del parent_partial
+
+    requires_partial_field = zen_partial is not None
+
     requires_zen_processing: Final[bool] = (
         bool(zen_meta)
         or bool(validated_wrappers)
         or any(uses_zen_processing(b) for b in builds_bases)
-        or (zen_partial and not HYDRA_SUPPORTS_PARTIAL)
+        or (bool(requires_partial_field) and not HYDRA_SUPPORTS_PARTIAL)
     )
 
-    if not requires_zen_processing and zen_partial:  # pragma: no cover
+    if base_zen_partial:
+        assert requires_zen_processing
+
+    del base_zen_partial
+
+    if not requires_zen_processing and requires_partial_field:  # pragma: no cover
         # TODO: require test-coverage once Hydra publishes nightly builds
         target_field = [
             (
@@ -1180,7 +1228,7 @@ def builds(
             (
                 PARTIAL_FIELD_NAME,
                 bool,
-                _utils.field(default=zen_partial, init=False),
+                _utils.field(default=bool(zen_partial), init=False),
             ),
         ]
     elif requires_zen_processing:
@@ -1198,14 +1246,23 @@ def builds(
             ),
         ]
 
-        if zen_partial:
+        if requires_partial_field:
             target_field.append(
                 (
-                    ZEN_PARTIAL_TARGET_FIELD_NAME,
+                    ZEN_PARTIAL_FIELD_NAME,
                     bool,
-                    _utils.field(default=True, init=False),
+                    _utils.field(default=bool(zen_partial), init=False),
                 ),
             )
+            if HYDRA_SUPPORTS_PARTIAL and base_hydra_partial:
+                # Must explicitly set _partial_=False to prevent inheritance
+                target_field.append(
+                    (
+                        PARTIAL_FIELD_NAME,
+                        bool,
+                        _utils.field(default=False, init=False),
+                    ),
+                )
 
         if zen_meta:
             target_field.append(
@@ -1254,6 +1311,9 @@ def builds(
             )
         ]
 
+    del base_hydra_partial
+    del requires_partial_field
+
     base_fields = target_field
 
     if hydra_recursive is not None:
@@ -1606,7 +1666,7 @@ def builds(
         base_fields.extend((name, Any, value) for name, value in zen_meta.items())
 
     if dataclass_name is None:
-        if zen_partial is False:
+        if zen_partial is not True:
             dataclass_name = f"Builds_{_utils.safe_name(target)}"
         else:
             dataclass_name = f"PartialBuilds_{_utils.safe_name(target)}"
@@ -1688,26 +1748,6 @@ def builds(
         dataclass_name, fields=sanitized_base_fields, bases=builds_bases, frozen=frozen
     )
 
-    if zen_partial is False and (
-        getattr(out, ZEN_PARTIAL_TARGET_FIELD_NAME, False)
-        or getattr(out, PARTIAL_FIELD_NAME, False)
-    ):
-        # `out._partial_=True` or `out._zen_partial=True` has been inherited; there
-        # is no way for users to override this, thus they must explicitly specify
-        # `zen_partial=True` in order to "opt-in" to this behavior.
-        raise TypeError(
-            BUILDS_ERROR_PREFIX
-            + "`builds(..., zen_partial=False, builds_bases=(...))` does not "
-            "permit `builds_bases` where a partial target has been specified."
-        )
-
-    if requires_zen_processing and hasattr(out, PARTIAL_FIELD_NAME):
-        raise TypeError(
-            BUILDS_ERROR_PREFIX
-            + "`builds(..., builds_bases=(...))` cannot use zen-processing features "
-            "while inheriting the field `_partial_: bool = ...`"
-        )
-
     out.__doc__ = (
         f"A structured config designed to {'partially ' if zen_partial else ''}initialize/call "
         f"`{target_path}` upon instantiation by hydra."
@@ -1721,6 +1761,13 @@ def builds(
 
     assert requires_zen_processing is uses_zen_processing(out)
 
+    # _partial_=True should never be relied on when zen-processing is being used.
+    assert not (
+        HYDRA_SUPPORTS_PARTIAL
+        and requires_zen_processing
+        and getattr(out, PARTIAL_FIELD_NAME, False)
+    )
+
     return cast(Union[Type[Builds[Importable]], Type[BuildsWithSig[Type[R], P]]], out)
 
 
@@ -1804,7 +1851,7 @@ def get_target(obj: HasTarget) -> Any:
     else:
         raise TypeError(
             f"`obj` must specify a target; i.e. it must have an attribute named"
-            f" {TARGET_FIELD_NAME} or named {ZEN_PARTIAL_TARGET_FIELD_NAME} that"
+            f" {TARGET_FIELD_NAME} or named {ZEN_PARTIAL_FIELD_NAME} that"
             f" points to a target-object or target-string"
         )
     target = getattr(obj, field_name)
diff --git a/src/hydra_zen/structured_configs/_make_config.py b/src/hydra_zen/structured_configs/_make_config.py
index ac35d2b1..6d423eff 100644
--- a/src/hydra_zen/structured_configs/_make_config.py
+++ b/src/hydra_zen/structured_configs/_make_config.py
@@ -18,8 +18,17 @@ from hydra_zen.structured_configs._implementations import sanitize_collection
 from hydra_zen.typing import SupportedPrimitive
 from hydra_zen.typing._implementations import DataClass, DataClass_, DefaultsList, Field
 
-from ._globals import CONVERT_FIELD_NAME, DEFAULTS_LIST_FIELD_NAME, RECURSIVE_FIELD_NAME
+from .._compatibility import HYDRA_SUPPORTS_PARTIAL
+from ._globals import (
+    CONVERT_FIELD_NAME,
+    DEFAULTS_LIST_FIELD_NAME,
+    PARTIAL_FIELD_NAME,
+    RECURSIVE_FIELD_NAME,
+    ZEN_PARTIAL_FIELD_NAME,
+    ZEN_TARGET_FIELD_NAME,
+)
 from ._implementations import _retain_type_info, builds, sanitized_field
+from ._type_guards import uses_zen_processing
 
 __all__ = ["ZenField", "make_config"]
 
@@ -462,9 +471,25 @@ def make_config(
             )
         )
 
-    return cast(
-        Type[DataClass],
-        make_dataclass(
-            cls_name=config_name, fields=config_fields, frozen=frozen, bases=bases
-        ),
+    out = make_dataclass(
+        cls_name=config_name, fields=config_fields, frozen=frozen, bases=bases
     )
+    if hasattr(out, ZEN_TARGET_FIELD_NAME) and not uses_zen_processing(out):
+        raise ValueError(
+            f"{out.__name__} inherits from base classes that overwrite some fields "
+            f"associated with zen-processing features. As a result, this config will "
+            f"not instantiate correctly."
+        )
+    if (
+        HYDRA_SUPPORTS_PARTIAL
+        and getattr(out, PARTIAL_FIELD_NAME, False)
+        and uses_zen_processing(out)
+    ):
+        raise ValueError(
+            f"{out.__name__} specifies both `{PARTIAL_FIELD_NAME}=True` and `"
+            f"{ZEN_PARTIAL_FIELD_NAME}=True`. This config will not instantiate "
+            f"correctly. This is typically caused by inheriting from multiple, "
+            f"conflicting configs."
+        )
+
+    return cast(Type[DataClass], out)
diff --git a/src/hydra_zen/structured_configs/_make_custom_builds.py b/src/hydra_zen/structured_configs/_make_custom_builds.py
index 67a044de..a379e1e8 100644
--- a/src/hydra_zen/structured_configs/_make_custom_builds.py
+++ b/src/hydra_zen/structured_configs/_make_custom_builds.py
@@ -42,7 +42,7 @@ del _builds_sig
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: Literal[False] = ...,
+    zen_partial: Literal[False, None] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     populate_full_signature: Literal[True],
@@ -74,7 +74,7 @@ def make_custom_builds_fn(
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: Literal[False] = ...,
+    zen_partial: Literal[False, None] = ...,
     populate_full_signature: Literal[False] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
@@ -90,7 +90,7 @@ def make_custom_builds_fn(
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: Literal[False] = ...,
+    zen_partial: Literal[False, None] = ...,
     populate_full_signature: bool,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
@@ -106,7 +106,7 @@ def make_custom_builds_fn(
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: Literal[False] = ...,
+    zen_partial: Literal[False, None] = ...,
     populate_full_signature: bool = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
@@ -122,7 +122,7 @@ def make_custom_builds_fn(
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: bool,
+    zen_partial: Union[bool, None],
     populate_full_signature: Literal[False] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
@@ -138,7 +138,7 @@ def make_custom_builds_fn(
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: bool,
+    zen_partial: Union[bool, None],
     populate_full_signature: bool,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
@@ -154,7 +154,7 @@ def make_custom_builds_fn(
 @overload
 def make_custom_builds_fn(
     *,
-    zen_partial: bool,
+    zen_partial: Union[bool, None],
     populate_full_signature: bool,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
@@ -168,7 +168,7 @@ def make_custom_builds_fn(
 
 def make_custom_builds_fn(
     *,
-    zen_partial: bool = False,
+    zen_partial: Optional[bool] = None,
     populate_full_signature: bool = False,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
     zen_meta: Optional[Mapping[str, Any]] = None,
diff --git a/src/hydra_zen/structured_configs/_type_guards.py b/src/hydra_zen/structured_configs/_type_guards.py
index 4d1c6b7f..8111103f 100644
--- a/src/hydra_zen/structured_configs/_type_guards.py
+++ b/src/hydra_zen/structured_configs/_type_guards.py
@@ -14,11 +14,13 @@ from ._globals import (
     JUST_FIELD_NAME,
     PARTIAL_FIELD_NAME,
     TARGET_FIELD_NAME,
-    ZEN_PARTIAL_TARGET_FIELD_NAME,
+    ZEN_PARTIAL_FIELD_NAME,
     ZEN_PROCESSING_LOCATION,
     ZEN_TARGET_FIELD_NAME,
 )
 
+__all__ = ["is_partial_builds", "uses_zen_processing"]
+
 # We need to check if things are Builds, Just, PartialBuilds to a higher
 # fidelity than is provided by `isinstance(..., <Protocol>)`. I.e. we want to
 # check that the desired attributes *and* that their values match those of the
@@ -67,6 +69,55 @@ def is_old_partial_builds(x: Any) -> bool:  # pragma: no cover
 
 
 def uses_zen_processing(x: Any) -> TypeGuard[Builds[Any]]:
+    """Returns `True` if the input is a targeted structured config that relies on
+    zen-processing features during its instantiation process. See notes for more details
+
+    Parameters
+    ----------
+    x : Any
+
+    Returns
+    -------
+    uses_zen : bool
+
+    Notes
+    -----
+    In order to support zen :ref:`meta-fields <meta-field>` and
+    :ref:`zen wrappers <zen-wrapper>`, hydra-zen redirects Hydra to an intermediary
+    function – `hydra_zen.funcs.zen_processing` – during instantiation; i.e.
+    `zen_processing` is made to be the `_target_` of the config and `_zen_target`
+    indicates the object that is ultimately being configured for instantiation.
+
+    Examples
+    --------
+    >>> from hydra_zen import builds, uses_zen_processing, to_yaml
+    >>> ConfA = builds(dict, a=1)
+    >>> ConfB = builds(dict, a=1, zen_partial=True)
+    >>> ConfC = builds(dict, a=1, zen_wrappers=lambda x: x)
+    >>> ConfD = builds(dict, a=1, zen_meta=dict(hidden_field=None))
+    >>> ConfE = builds(dict, a=1, zen_meta=dict(hidden_field=None), zen_partial=True)
+    >>> uses_zen_processing(ConfA)
+    False
+    >>> uses_zen_processing(ConfB)
+    False
+    >>> uses_zen_processing(ConfC)
+    True
+    >>> uses_zen_processing(ConfD)
+    True
+    >>> uses_zen_processing(ConfE)
+    True
+
+    Demonstrating the indirection that is used to facilitate zen-processing features.
+
+    >>> print(to_yaml(ConfE))
+    _target_: hydra_zen.funcs.zen_processing
+    _zen_target: builtins.dict
+    _zen_partial: true
+    _zen_exclude:
+    - hidden_field
+    a: 1
+    hidden_field: null
+    """
     if not is_builds(x) or not hasattr(x, ZEN_TARGET_FIELD_NAME):
         return False
     attr = _get_target(x)
@@ -76,6 +127,55 @@ def uses_zen_processing(x: Any) -> TypeGuard[Builds[Any]]:
 
 
 def is_partial_builds(x: Any) -> TypeGuard[PartialBuilds[Any]]:
+    """
+    Returns `True` if the input is a targeted structured config that entails partial
+    instantiation, either via `_partial_=True` [1]_ or via `_zen_partial=True`.
+
+    Parameters
+    ----------
+    x : Any
+
+    Returns
+    -------
+    is_partial_config : bool
+
+    References
+    ----------
+    .. [1] https://hydra.cc/docs/advanced/instantiate_objects/overview/#partial-instantiation
+
+    See Also
+    --------
+    uses_zen_processing
+
+    Examples
+    --------
+    >>> from hydra_zen import is_partial_builds
+
+    An example involving a basic structured config
+
+    >>> from dataclasses import dataclass
+    >>> @dataclass
+    ... class A:
+    ...     _target_ : str = 'builtins.int'
+    ...     _partial_ : bool = True
+    >>> is_partial_builds(A)
+    True
+    >>> is_partial_builds(A(_partial_=False))
+    False
+
+    An example of a config that leverages partial instantiation via zen-processing
+
+    >>> from hydra_zen import builds, uses_zen_processing, instantiate
+    >>> Conf = builds(int, 0, zen_partial=True, zen_meta=dict(a=1))
+    >>> hasattr(Conf, "_partial_")
+    False
+    >>> uses_zen_processing(Conf)
+    True
+    >>> is_partial_builds(Conf)
+    True
+    >>> instantiate(Conf)
+    functools.partial(<class 'int'>, 0)
+    """
     if is_builds(x):
         return (
             # check if partial'd config via Hydra
@@ -84,6 +184,6 @@ def is_partial_builds(x: Any) -> TypeGuard[PartialBuilds[Any]]:
         ) or (
             # check if partial'd config via `zen_processing`
             uses_zen_processing(x)
-            and (getattr(x, ZEN_PARTIAL_TARGET_FIELD_NAME, False) is True)
+            and (getattr(x, ZEN_PARTIAL_FIELD_NAME, False) is True)
         )
     return False
diff --git a/src/hydra_zen/typing/_builds_overloads.py b/src/hydra_zen/typing/_builds_overloads.py
index 43d034b3..edb95972 100644
--- a/src/hydra_zen/typing/_builds_overloads.py
+++ b/src/hydra_zen/typing/_builds_overloads.py
@@ -43,7 +43,7 @@ class StdBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Callable[P, R],
         *,
-        zen_partial: Literal[False] = ...,
+        zen_partial: Literal[False, None] = ...,
         populate_full_signature: Literal[True],
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -62,7 +62,7 @@ class StdBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Importable,
         *pos_args: SupportedPrimitive,
-        zen_partial: Literal[False] = ...,
+        zen_partial: Literal[False, None] = ...,
         populate_full_signature: bool = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -102,7 +102,7 @@ class StdBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Importable,
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = ...,
+        zen_partial: Optional[bool] = ...,
         populate_full_signature: Literal[False] = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -125,7 +125,7 @@ class StdBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool,
+        zen_partial: Optional[bool],
         populate_full_signature: bool = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -147,7 +147,7 @@ class StdBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = False,
+        zen_partial: Optional[bool] = None,
         populate_full_signature: bool = False,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = None,
@@ -175,7 +175,7 @@ class FullBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Callable[P, R],
         *,
-        zen_partial: Literal[False] = ...,
+        zen_partial: Literal[False, None] = ...,
         populate_full_signature: Literal[True] = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -194,7 +194,7 @@ class FullBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Importable,
         *pos_args: SupportedPrimitive,
-        zen_partial: Literal[False] = ...,
+        zen_partial: Literal[False, None] = ...,
         populate_full_signature: bool = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -234,7 +234,7 @@ class FullBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Importable,
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = ...,
+        zen_partial: Optional[bool] = ...,
         populate_full_signature: Literal[False],
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -254,7 +254,7 @@ class FullBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool,
+        zen_partial: Optional[bool],
         populate_full_signature: bool = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -276,7 +276,7 @@ class FullBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = False,
+        zen_partial: Optional[bool] = None,
         populate_full_signature: bool = True,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = None,
@@ -324,7 +324,7 @@ class PBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Callable[P, R],
         *,
-        zen_partial: Literal[False],
+        zen_partial: Literal[False, None],
         populate_full_signature: Literal[True],
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -343,7 +343,7 @@ class PBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Importable,
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = ...,
+        zen_partial: Optional[bool] = ...,
         populate_full_signature: Literal[False] = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -366,7 +366,7 @@ class PBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool,
+        zen_partial: Optional[bool],
         populate_full_signature: bool = ...,
         hydra_recursive: Optional[bool] = ...,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
@@ -390,7 +390,7 @@ class PBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = ...,
+        zen_partial: Optional[bool] = ...,
         populate_full_signature: bool,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = ...,
@@ -412,7 +412,7 @@ class PBuilds(Protocol):  # pragma: no cover
         self,
         __hydra_target: Union[Callable[P, R], Importable],
         *pos_args: SupportedPrimitive,
-        zen_partial: bool = True,
+        zen_partial: Optional[bool] = True,
         populate_full_signature: bool = False,
         zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
         zen_meta: Optional[Mapping[str, SupportedPrimitive]] = None,


Test patch:
diff --git a/tests/annotations/declarations.py b/tests/annotations/declarations.py
index 84d3e7b3..dd8f8985 100644
--- a/tests/annotations/declarations.py
+++ b/tests/annotations/declarations.py
@@ -788,7 +788,7 @@ def check_targeted_dataclass():
 
 def check_overloads_arent_too_restrictive():
     def caller(
-        zen_partial: bool,
+        zen_partial: Optional[bool],
         zen_wrappers: ZenWrappers[Callable[..., Any]],
         zen_meta: Optional[Mapping[str, SupportedPrimitive]],
         populate_full_signature: bool,
@@ -876,60 +876,62 @@ def check_hydra_target_pos_only(partial_builds: PBuilds, full_builds: FullBuilds
 # the following tests caught regressions in builds' overloads
 
 
-def check_partial_narrowing_builds(x: bool):
+def check_partial_narrowing_builds(x: bool, partial_: Optional[bool]):
     def f(x: int):
         return 1
 
-    maybe_full_sig = builds(f, zen_partial=x, populate_full_signature=x)
+    maybe_full_sig = builds(f, zen_partial=partial_, populate_full_signature=x)
     # could have full-sig; should raise
     maybe_full_sig()  # type: ignore
 
-    not_full_sig = builds(f, zen_partial=x, populate_full_signature=False)
+    not_full_sig = builds(f, zen_partial=partial_, populate_full_signature=False)
     # can't have full sig,
     not_full_sig()
 
-    not_full_sig2 = builds(f, zen_partial=x)
+    not_full_sig2 = builds(f, zen_partial=partial_)
     # can't have full-sig
     not_full_sig2()
 
 
 def check_partial_narrowing_std_and_partial(
-    builds_fn: Union[StdBuilds, PBuilds], x: bool
+    builds_fn: Union[StdBuilds, PBuilds], x: bool, partial_: Optional[bool]
 ):
     def f(x: int):
         return 1
 
-    maybe_full_sig = builds_fn(f, zen_partial=x, populate_full_signature=x)
+    maybe_full_sig = builds_fn(f, zen_partial=partial_, populate_full_signature=x)
     # could have full-sig; should raise
     maybe_full_sig()  # type: ignore
 
-    not_full_sig = builds_fn(f, zen_partial=x, populate_full_signature=False)
+    not_full_sig = builds_fn(f, zen_partial=partial_, populate_full_signature=False)
     # can't have full sig,
     not_full_sig()
 
-    not_full_sig2 = builds_fn(f, zen_partial=x)
+    not_full_sig2 = builds_fn(f, zen_partial=partial_)
     # can't have full-sig
     not_full_sig2()
 
 
-def check_partial_narrowing_full(builds_fn: FullBuilds, x: bool):
+def check_partial_narrowing_full(
+    builds_fn: FullBuilds, x: bool, partial_: Optional[bool]
+):
     def f(x: int):
         return 1
 
-    maybe_full_sig = builds_fn(f, zen_partial=x, populate_full_signature=x)
+    maybe_full_sig = builds_fn(f, zen_partial=partial_, populate_full_signature=x)
     # could have full-sig; should raise
     maybe_full_sig()  # type: ignore
 
-    not_full_sig = builds_fn(f, zen_partial=x, populate_full_signature=False)
+    not_full_sig = builds_fn(f, zen_partial=partial_, populate_full_signature=False)
     # can't have full sig,
     not_full_sig()
 
-    yes_full_sig = builds_fn(f, zen_partial=x)
+    yes_full_sig = builds_fn(f, zen_partial=partial_)
     # can't have full-sig
     yes_full_sig()  # type: ignore
 
 
-def check_make_custom_builds_overloads(boolean: bool):
+def check_make_custom_builds_overloads(boolean: bool, optional_boolean: Optional[bool]):
     Parent = builds(int)
 
     # Returns `StdBuilds`
@@ -976,41 +978,51 @@ def check_make_custom_builds_overloads(boolean: bool):
     )
 
     reveal_type(
-        make_custom_builds_fn(zen_partial=boolean),
+        make_custom_builds_fn(zen_partial=optional_boolean),
         expected_text="PBuilds | StdBuilds",
     )
 
     reveal_type(
-        make_custom_builds_fn(populate_full_signature=False, zen_partial=boolean),
+        make_custom_builds_fn(
+            populate_full_signature=False, zen_partial=optional_boolean
+        ),
         expected_text="PBuilds | StdBuilds",
     )
     reveal_type(
         make_custom_builds_fn(
-            populate_full_signature=True, zen_partial=boolean, builds_bases=(Parent,)
+            populate_full_signature=True,
+            zen_partial=optional_boolean,
+            builds_bases=(Parent,),
         ),
         expected_text="PBuilds | StdBuilds",
     )
 
     reveal_type(
         make_custom_builds_fn(
-            populate_full_signature=boolean, zen_partial=boolean, builds_bases=(Parent,)
+            populate_full_signature=boolean,
+            zen_partial=optional_boolean,
+            builds_bases=(Parent,),
         ),
         expected_text="PBuilds | StdBuilds",
     )
 
     reveal_type(
-        make_custom_builds_fn(zen_partial=boolean, builds_bases=(Parent,)),
+        make_custom_builds_fn(zen_partial=optional_boolean, builds_bases=(Parent,)),
         expected_text="PBuilds | StdBuilds",
     )
 
     # Returns `FullBuilds | PBuilds | StdBuilds`
     reveal_type(
-        make_custom_builds_fn(populate_full_signature=True, zen_partial=boolean),
+        make_custom_builds_fn(
+            populate_full_signature=True, zen_partial=optional_boolean
+        ),
         expected_text="FullBuilds | PBuilds | StdBuilds",
     )
 
     reveal_type(
-        make_custom_builds_fn(populate_full_signature=boolean, zen_partial=boolean),
+        make_custom_builds_fn(
+            populate_full_signature=boolean, zen_partial=optional_boolean
+        ),
         expected_text="FullBuilds | PBuilds | StdBuilds",
     )
 
diff --git a/tests/custom_strategies.py b/tests/custom_strategies.py
index 71d3b6c2..bd5c3667 100644
--- a/tests/custom_strategies.py
+++ b/tests/custom_strategies.py
@@ -26,7 +26,7 @@ wrapper_strat = single_wrapper_strat | st.lists(single_wrapper_strat)
 
 
 _valid_builds_strats = dict(
-    zen_partial=st.booleans(),
+    zen_partial=st.none() | st.booleans(),
     zen_wrappers=wrapper_strat,
     zen_meta=st.dictionaries(
         st.text(string.ascii_lowercase, min_size=1, max_size=2).map(lambda x: "_" + x),
diff --git a/tests/test_dataclass_semantics.py b/tests/test_dataclass_semantics.py
index eb9bb29c..5e83e21c 100644
--- a/tests/test_dataclass_semantics.py
+++ b/tests/test_dataclass_semantics.py
@@ -19,7 +19,7 @@ def f(x, y, z: int = 3):
     return x, y, z
 
 
-@given(full_sig=st.booleans(), partial=st.booleans())
+@given(full_sig=st.booleans(), partial=st.none() | st.booleans())
 def test_builds_produces_dataclass(full_sig: bool, partial: bool):
 
     if full_sig and not partial:
@@ -42,7 +42,7 @@ def f_2(x, y, z):
 
 
 @pytest.mark.parametrize("full_sig", [True, False])
-@pytest.mark.parametrize("partial", [True, False])
+@pytest.mark.parametrize("partial", [True, False, None])
 def test_chain_builds_of_targets_with_common_interfaces(full_sig, partial):
 
     # Note that conf_1 and conf_2 target `f` whereas conf_3 targets `f_three_vars`,
@@ -68,7 +68,7 @@ def test_chain_builds_of_targets_with_common_interfaces(full_sig, partial):
 
 
 @pytest.mark.parametrize("full_sig", [True, False])
-@pytest.mark.parametrize("partial", [True, False])
+@pytest.mark.parametrize("partial", [True, False, None])
 def test_pos_args_with_inheritance(full_sig, partial):
 
     conf_1 = builds(f_three_vars, 1, 2)
@@ -178,3 +178,20 @@ def test_hydra_settings_can_be_inherited(recursive, convert, via_builds):
         assert Child._convert_ is Base._convert_
     else:
         assert not hasattr(Child, "_convert_")
+
+
+@given(
+    target=st.sampled_from([int, str]),
+    zen_partial=st.none() | st.booleans(),
+    name=st.none() | st.just("CustomName"),
+)
+def test_dataclass_name(target, zen_partial, name):
+    Conf = builds(target, zen_partial=zen_partial, dataclass_name=name)
+    if name is not None:
+        assert Conf.__name__ == "CustomName"
+        return
+    target_name = "str" if target is str else "int"
+    if zen_partial is True:
+        assert Conf.__name__ == f"PartialBuilds_{target_name}"
+    else:
+        assert Conf.__name__ == f"Builds_{target_name}"
diff --git a/tests/test_hydra_behaviors.py b/tests/test_hydra_behaviors.py
index 72b2b7b2..19ca541d 100644
--- a/tests/test_hydra_behaviors.py
+++ b/tests/test_hydra_behaviors.py
@@ -25,7 +25,7 @@ def wrapper(obj):
     convert=st.sampled_from(["none", "all", "partial"]),
     recursive=st.booleans(),
     sig=st.booleans(),
-    partial=st.booleans(),
+    partial=st.none() | st.booleans(),
     meta=st.none() | st.just(dict(meta=1)),
     wrappers=st.none() | st.just(wrapper) | st.lists(st.just(wrapper)),
     kwargs=st.dictionaries(keys=st.sampled_from(["x", "y", "z"]), values=st.floats()),
diff --git a/tests/test_hydrated_dataclass.py b/tests/test_hydrated_dataclass.py
index ee89247e..4becd024 100644
--- a/tests/test_hydrated_dataclass.py
+++ b/tests/test_hydrated_dataclass.py
@@ -6,6 +6,11 @@ from dataclasses import is_dataclass
 import pytest
 
 from hydra_zen import hydrated_dataclass, instantiate
+from hydra_zen._compatibility import HYDRA_SUPPORTS_PARTIAL
+from hydra_zen.structured_configs._globals import (
+    PARTIAL_FIELD_NAME,
+    ZEN_PARTIAL_FIELD_NAME,
+)
 
 
 def f1(x, y):
@@ -47,3 +52,24 @@ def test_pos_args():
         z: int = 3
 
     assert instantiate(Conf) == (1, 2, 3)
+
+
+@pytest.mark.parametrize("zen_partial", [None, False, True])
+def test_partial(zen_partial):
+    kw = {}
+    if zen_partial is not None:
+        kw["zen_partial"] = zen_partial
+
+    @hydrated_dataclass(f2, 1, 2, **kw)
+    class Conf:
+        z: int = 3
+
+    if zen_partial is None:
+        assert not hasattr(Conf, PARTIAL_FIELD_NAME) and not hasattr(
+            Conf, ZEN_PARTIAL_FIELD_NAME
+        )
+    else:
+        if HYDRA_SUPPORTS_PARTIAL:
+            assert getattr(Conf, PARTIAL_FIELD_NAME) is zen_partial
+        else:
+            assert getattr(Conf, ZEN_PARTIAL_FIELD_NAME) is zen_partial
diff --git a/tests/test_roundtrips.py b/tests/test_roundtrips.py
index 19df2022..c8db67c4 100644
--- a/tests/test_roundtrips.py
+++ b/tests/test_roundtrips.py
@@ -1,6 +1,5 @@
 # Copyright (c) 2022 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
-
 import datetime
 import math
 import operator
@@ -11,7 +10,7 @@ import statistics
 import string
 from collections import Counter, defaultdict, deque
 from dataclasses import dataclass
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
 
 import hypothesis.strategies as st
 import pytest
@@ -95,10 +94,10 @@ def f(x, y=dict(a=2)):
 
 
 @pytest.mark.parametrize("full_sig", [True, False])
-@pytest.mark.parametrize("partial", [True, False])
+@pytest.mark.parametrize("partial", [True, False, None])
 @pytest.mark.parametrize("named_arg", [True, False])
 def test_builds_roundtrips_with_mutable_values(
-    full_sig: bool, partial: bool, named_arg: bool
+    full_sig: bool, partial: Optional[bool], named_arg: bool
 ):
     # tests mutable user-specified value and default value
     if named_arg:
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
index add9ea21..cadf8d12 100644
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -145,9 +145,9 @@ def f3(x: str, *args, y: int = 22, z=[2], **kwargs):
 
 
 @pytest.mark.parametrize("include_extra_param", [False, True])
-@pytest.mark.parametrize("partial", [False, True])
+@pytest.mark.parametrize("partial", [None, False, True])
 def test_builds_with_full_sig_mirrors_target_sig(
-    include_extra_param: bool, partial: bool
+    include_extra_param: bool, partial: Optional[bool]
 ):
 
     kwargs = dict(named_param=2) if include_extra_param else {}
diff --git a/tests/test_validation.py b/tests/test_validation.py
index dc832f89..90d60a61 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -50,7 +50,7 @@ def test_hydra_partial_via_hydrated_dataclass_is_error():
             pass
 
 
-@given(hydra_partial=st.booleans(), zen_partial=st.booleans())
+@given(hydra_partial=st.booleans(), zen_partial=st.none() | st.booleans())
 def test_specifying_hydra_partial_and_zen_partial_raises(
     hydra_partial: bool, zen_partial: bool
 ):
@@ -153,7 +153,7 @@ def passthrough(*args, **kwargs):
         ),
     ],
 )
-@given(partial=st.booleans(), full_sig=st.booleans())
+@given(partial=st.none() | st.booleans(), full_sig=st.booleans())
 def test_builds_raises_when_user_specified_args_violate_sig(
     func, args, kwargs, full_sig, partial
 ):
@@ -211,7 +211,7 @@ def f(y):
     return y
 
 
-@given(partial=st.booleans(), full_sig=st.booleans())
+@given(partial=st.none() | st.booleans(), full_sig=st.booleans())
 def test_builds_raises_when_base_has_invalid_arg(full_sig, partial):
 
     with pytest.raises(TypeError):
@@ -263,30 +263,6 @@ def f2():
     pass
 
 
-@given(partial=st.booleans(), full_sig=st.booleans())
-def test_builds_raises_when_base_with_partial_target_is_specified(
-    partial: bool, full_sig: bool
-):
-
-    partiald_conf = builds(f2, zen_partial=True)
-
-    if not partial:
-        with pytest.raises(TypeError):
-            builds(
-                f2,
-                populate_full_signature=full_sig,
-                zen_partial=partial,
-                builds_bases=(partiald_conf,),
-            )
-    else:
-        builds(
-            f2,
-            populate_full_signature=full_sig,
-            zen_partial=partial,
-            builds_bases=(partiald_conf,),
-        )
-
-
 def func_with_var_kwargs(**x):
     return x
 
@@ -304,7 +280,7 @@ class Class:
 
 
 @pytest.mark.parametrize("not_callable", [1, "a", None, [1, 2], Class()])
-@given(partial=st.booleans(), full_sig=st.booleans())
+@given(partial=st.none() | st.booleans(), full_sig=st.booleans())
 def test_builds_raises_on_non_callable_target(not_callable, partial, full_sig):
     with pytest.raises(TypeError):
         builds(not_callable, populate_full_signature=full_sig, zen_partial=partial)
@@ -334,7 +310,7 @@ def test_builds_input_validation(param_name: str, value):
 
 def test_just_raises_with_legible_message():
     with pytest.raises(HydraZenUnsupportedPrimitiveError):
-        just(Class())  # type: ignore
+        just(Class())
 
 
 def test_hydrated_dataclass_from_instance_raise():
@@ -347,7 +323,7 @@ def test_hydrated_dataclass_from_instance_raise():
         hydrated_dataclass(dict)(instance_of_a)  # type: ignore
 
 
-@given(partial=st.booleans(), full_sig=st.booleans())
+@given(partial=st.none() | st.booleans(), full_sig=st.booleans())
 def test_builds_raises_for_unimportable_target(partial, full_sig):
     def unreachable():
         pass
@@ -400,7 +376,7 @@ def f_meta_sig(x, *args, y, **kwargs):
         st.sampled_from(["x", "y", "args", "kwargs", "z"]), st.integers()
     ),
     pop_sig=st.booleans(),
-    partial=st.booleans(),
+    partial=st.none() | st.booleans(),
 )
 def test_meta_fields_colliding_with_sig_raises(
     meta_fields, pop_sig: bool, partial: bool
@@ -426,7 +402,7 @@ def test_meta_fields_colliding_with_sig_raises(
     meta_fields=st.dictionaries(
         st.sampled_from(["x", "y", "args", "kwargs", "z"]), st.integers()
     ),
-    partial=st.booleans(),
+    partial=st.none() | st.booleans(),
 )
 def test_meta_fields_colliding_with_user_provided_kwargs_raises(
     meta_fields, partial: bool
diff --git a/tests/test_validation_py38.py b/tests/test_validation_py38.py
index a293ac9f..c698ddb2 100644
--- a/tests/test_validation_py38.py
+++ b/tests/test_validation_py38.py
@@ -21,7 +21,7 @@ def xy_are_pos_only(x, y, /):
 
 
 @pytest.mark.parametrize("func", [x_is_pos_only])
-@given(partial=st.booleans(), full_sig=st.booleans())
+@given(partial=st.none() | st.booleans(), full_sig=st.booleans())
 def test_builds_runtime_validation_pos_only_not_nameable(func, full_sig, partial):
     with pytest.raises(TypeError):
         builds(func, x=10, zen_partial=partial, populate_full_signature=full_sig)
@@ -30,7 +30,7 @@ def test_builds_runtime_validation_pos_only_not_nameable(func, full_sig, partial
 @given(
     x=valid_hydra_literals,
     full_sig=st.booleans(),
-    partial=st.booleans(),
+    partial=st.none() | st.booleans(),
 )
 def test_roundtrip_pos_only(x, full_sig: bool, partial: bool):
     cfg = builds(
diff --git a/tests/test_zen_processing/test_inheritance.py b/tests/test_zen_processing/test_inheritance.py
index 0bf1cce6..1d7e42b7 100644
--- a/tests/test_zen_processing/test_inheritance.py
+++ b/tests/test_zen_processing/test_inheritance.py
@@ -1,18 +1,24 @@
 from dataclasses import dataclass
 from functools import partial
+from typing import Tuple, Type
 
 import hypothesis.strategies as st
 import pytest
 from hypothesis import given
 
-from hydra_zen import builds, instantiate, to_yaml
+from hydra_zen import builds, instantiate, make_config, to_yaml
 from hydra_zen._compatibility import HYDRA_SUPPORTS_PARTIAL
 from hydra_zen.structured_configs._globals import (
     PARTIAL_FIELD_NAME,
+    ZEN_PARTIAL_FIELD_NAME,
     ZEN_PROCESSING_LOCATION,
 )
-from hydra_zen.structured_configs._type_guards import uses_zen_processing
+from hydra_zen.structured_configs._type_guards import (
+    is_partial_builds,
+    uses_zen_processing,
+)
 from hydra_zen.typing import HydraPartialBuilds, ZenPartialBuilds
+from hydra_zen.typing._implementations import DataClass_, Partial
 from tests import sorted_yaml
 
 
@@ -84,6 +90,19 @@ def test_partial_with_inherited_zen_processing(args, meta, wrappers):
     assert out() == args
 
 
+@dataclass
+class HydraNoPartial:
+    _target_: str = "builtins.dict"
+    a: int = 1
+
+
+@dataclass
+class ZenNoPartial:
+    _target_: str = ZEN_PROCESSING_LOCATION
+    _zen_target: str = "builtins.dict"
+    a: int = 1
+
+
 @dataclass
 class ZenPartialFalse:
     _target_: str = ZEN_PROCESSING_LOCATION
@@ -114,42 +133,213 @@ class HydraPartialTrue:
     a: int = 1
 
 
-@pytest.mark.parametrize("Parent", [ZenPartialFalse, HydraPartialFalse])
-def test_inherited_partial_false_fields(Parent):
-    if not HYDRA_SUPPORTS_PARTIAL and Parent is HydraPartialFalse:
-        pytest.mark.skip("Hydra version doesn't support instantiate API")
+@dataclass
+class ZenFalseHydraTrue:
+    _target_: str = ZEN_PROCESSING_LOCATION
+    _zen_partial: bool = False
+    _partial_: bool = True
+    _zen_target: str = "builtins.dict"
+    a: int = 1
 
-    Conf = builds(dict, builds_bases=(Parent,))  # should be OK
-    assert instantiate(Conf) == instantiate(Parent)
 
+@dataclass
+class ZenTrueHydraFalse:
+    _target_: str = ZEN_PROCESSING_LOCATION
+    _zen_partial: bool = True
+    _partial_: bool = False
+    _zen_target: str = "builtins.dict"
+    a: int = 1
 
-def test_inherit_hydra_partial_with_zen_processing_raises():
-    with pytest.raises(TypeError):
-        builds(
-            dict, zen_meta=dict(a=1), zen_partial=True, builds_bases=(HydraPartialTrue,)
-        )
+
+@dataclass
+class ZenTrueHydraTrue:
+    _target_: str = ZEN_PROCESSING_LOCATION
+    _zen_partial: bool = True
+    _partial_: bool = True
+    _zen_target: str = "builtins.dict"
+    a: int = 1
 
 
-@pytest.mark.parametrize("Parent", [ZenPartialTrue, HydraPartialTrue])
-def test_zen_partial_false_raises_for_partiald_parents(Parent):
-    with pytest.raises(TypeError):
-        builds(dict, zen_partial=False, builds_bases=(Parent,))
+@dataclass
+class ZenFalseHydraFalse:
+    _target_: str = ZEN_PROCESSING_LOCATION
+    _zen_partial: bool = False
+    _partial_: bool = False
+    _zen_target: str = "builtins.dict"
+    a: int = 1
 
 
-@pytest.mark.parametrize(
-    "Parent", [ZenPartialFalse, HydraPartialFalse, ZenPartialTrue, HydraPartialTrue]
+parents_strat = st.lists(
+    st.sampled_from(
+        [
+            HydraNoPartial,
+            ZenNoPartial,
+            HydraPartialTrue,
+            HydraPartialFalse,
+            ZenPartialTrue,
+            ZenPartialFalse,
+            ZenTrueHydraFalse,
+            ZenFalseHydraTrue,
+            ZenTrueHydraTrue,
+            ZenFalseHydraFalse,
+        ]
+    ),
+    unique=True,
+).map(tuple)
+
+
+@given(
+    child_partial=st.none() | st.booleans(),
+    zen_meta=st.sampled_from([{}, dict(meta=True)]),
+    parents=parents_strat,
 )
-def test_zen_partial_true_holds_for_all_inheritance(Parent):
-    def make_conf():
-        return builds(dict, b=2, zen_partial=True, builds_bases=(Parent,))
+def test_partial_via_inheritance(
+    child_partial: bool, zen_meta: dict, parents: Tuple[Type[DataClass_], ...]
+):
+
+    expected_out = dict(child_field=2)
+    if parents:
+        expected_out["a"] = 1
+
+    parent_partial = None
+    if child_partial is None:
+        # First parent with partial explicit partial field dictates inheritance
+        for p in parents:
+            if HYDRA_SUPPORTS_PARTIAL and hasattr(p, PARTIAL_FIELD_NAME):
+                parent_partial = getattr(p, PARTIAL_FIELD_NAME)
 
-    if not HYDRA_SUPPORTS_PARTIAL and hasattr(Parent, PARTIAL_FIELD_NAME):
-        with pytest.raises(TypeError):
-            make_conf()
-        return
+            if hasattr(p, ZEN_PARTIAL_FIELD_NAME):
+                parent_partial = parent_partial or getattr(p, ZEN_PARTIAL_FIELD_NAME)
 
-    Conf = make_conf()
+            if parent_partial is not None:
+                break
 
+    Conf = builds(
+        dict,
+        child_field=2,
+        zen_partial=child_partial,
+        zen_meta=zen_meta,
+        builds_bases=parents,
+    )
+
+    expected_partial = bool(
+        child_partial if child_partial is not None else parent_partial
+    )
+
+    assert is_partial_builds(Conf) is expected_partial
+
+    if hasattr(Conf, PARTIAL_FIELD_NAME) and not HYDRA_SUPPORTS_PARTIAL:
+        expected_out[PARTIAL_FIELD_NAME] = getattr(Conf, PARTIAL_FIELD_NAME)
+
+    # check actual instantiation behavior
     out = instantiate(Conf)
-    assert isinstance(out, partial)
-    assert out() == {"a": 1, "b": 2}
+    if expected_partial:
+        out: Partial[dict]
+        assert out() == expected_out
+    else:
+        assert isinstance(out, dict)
+        assert out == expected_out
+
+
+@given(
+    child_partial=st.none() | st.booleans(),
+    zen_meta=st.sampled_from([{}, dict(meta=True)]),
+    parents=parents_strat,
+)
+def test_instantiation_never_produces_partiald_zen_processing(
+    child_partial: bool, zen_meta: dict, parents: Tuple[Type[DataClass_], ...]
+):
+
+    Conf = builds(
+        dict, zen_partial=child_partial, zen_meta=zen_meta, builds_bases=parents
+    )
+    out = instantiate(Conf)
+    if isinstance(out, partial):
+        assert out.func is dict
+
+
+@given(
+    child_partial=st.none() | st.booleans(),
+    zen_meta=st.sampled_from([{}, dict(meta=True)]),
+    parents=parents_strat,
+)
+def test_partial_field_set_only_when_necessary(
+    child_partial: bool, zen_meta: dict, parents: Tuple[Type[DataClass_], ...]
+):
+    Conf = builds(
+        dict, zen_partial=child_partial, zen_meta=zen_meta, builds_bases=parents
+    )
+    # partial field should not be set unless necessary
+    if child_partial is None and all(
+        not hasattr(p, PARTIAL_FIELD_NAME) and not hasattr(p, ZEN_PARTIAL_FIELD_NAME)
+        for p in parents
+    ):
+        assert not hasattr(Conf, PARTIAL_FIELD_NAME)
+        assert not hasattr(Conf, ZEN_PARTIAL_FIELD_NAME)
+    else:
+        assert hasattr(Conf, PARTIAL_FIELD_NAME) or hasattr(
+            Conf, ZEN_PARTIAL_FIELD_NAME
+        )
+
+
+@pytest.mark.parametrize(
+    "parents, expected_partial",
+    [
+        ((), False),
+        ((HydraNoPartial,), False),
+        ((HydraPartialFalse,), False),
+        ((HydraPartialTrue,), HYDRA_SUPPORTS_PARTIAL),
+        ((ZenNoPartial,), False),
+        ((ZenPartialFalse,), False),
+        ((ZenPartialTrue,), True),
+        ((ZenFalseHydraFalse,), False),
+        ((ZenFalseHydraTrue,), HYDRA_SUPPORTS_PARTIAL),
+        ((ZenTrueHydraFalse,), True),
+        ((ZenTrueHydraTrue,), True),
+        ((HydraNoPartial, HydraPartialFalse), False),
+        ((HydraNoPartial, HydraPartialTrue), HYDRA_SUPPORTS_PARTIAL),
+        ((HydraPartialFalse, HydraPartialTrue), False),
+        ((HydraPartialTrue, HydraPartialFalse), HYDRA_SUPPORTS_PARTIAL),
+        ((ZenNoPartial, HydraPartialTrue), HYDRA_SUPPORTS_PARTIAL),
+        ((HydraPartialTrue, ZenPartialFalse), HYDRA_SUPPORTS_PARTIAL),
+        ((HydraPartialTrue, ZenPartialTrue), True),
+        ((HydraPartialFalse, ZenPartialTrue), not HYDRA_SUPPORTS_PARTIAL),
+        ((ZenPartialFalse, HydraPartialTrue), False),
+    ],
+)
+@pytest.mark.parametrize("meta", [{}, dict(meta=True)])
+def test_partial_via_inheritance_explicit_cases(
+    parents: Tuple[Type[DataClass_], ...],
+    expected_partial: bool,
+    meta,
+):
+    Conf = builds(int, zen_meta=meta, builds_bases=parents)
+    assert is_partial_builds(Conf) is expected_partial
+
+
+@pytest.mark.parametrize("Parent", [ZenPartialFalse, HydraPartialFalse])
+def test_inherited_partial_false_fields(Parent):
+    if not HYDRA_SUPPORTS_PARTIAL and Parent is HydraPartialFalse:
+        pytest.mark.skip("Hydra version doesn't support instantiate API")
+
+    Conf = builds(dict, builds_bases=(Parent,))  # should be OK
+    assert instantiate(Conf) == instantiate(Parent)
+
+
+def test_make_config_catches_multiple_inheritance_conflicting_with_zen_processing():
+    @dataclass
+    class A:
+        _target_: str = "builtins.str"
+        _partial_: bool = True
+
+    B = builds(int, zen_partial=True, zen_meta=dict(a=1))
+
+    with pytest.raises(ValueError):
+        # Overwrites _target_=hydra_zen.funcs.zen_processing
+        # leading to "corrupt" state
+        _ = make_config(bases=(A, B))
+
+    if HYDRA_SUPPORTS_PARTIAL:
+        with pytest.raises(ValueError):
+            # Specifies _partial_=True and _zen_partial=True
+            _ = make_config(bases=(B, A))
diff --git a/tests/test_zen_processing/test_meta_fields.py b/tests/test_zen_processing/test_meta_fields.py
index dd713961..c1057ed3 100644
--- a/tests/test_zen_processing/test_meta_fields.py
+++ b/tests/test_zen_processing/test_meta_fields.py
@@ -27,7 +27,7 @@ def f(*args, **kwargs):
         ),
         st.integers(),
     ),
-    zen_partial=st.booleans(),
+    zen_partial=st.none() | st.booleans(),
     pop_sig=st.booleans(),
 )
 def test_basic_zen_meta_behavior(
@@ -77,7 +77,7 @@ def f_concrete_sig(a, b):
         ),
         st.integers(),
     ),
-    zen_partial=st.booleans(),
+    zen_partial=st.none() | st.booleans(),
     pop_sig=st.booleans(),
 )
 def test_basic_zen_meta_behavior_vs_concrete_sig(
diff --git a/tests/test_zen_processing/test_zen_wrappers.py b/tests/test_zen_processing/test_zen_wrappers.py
index 2891e1ba..e72878e2 100644
--- a/tests/test_zen_processing/test_zen_wrappers.py
+++ b/tests/test_zen_processing/test_zen_wrappers.py
@@ -126,7 +126,7 @@ a_tracked_wrapper = st.sampled_from(tracked_funcs)
     kwargs=st.dictionaries(
         st.text(string.ascii_lowercase, min_size=1, max_size=1), st.integers()
     ),
-    zen_partial=st.booleans(),
+    zen_partial=st.none() | st.booleans(),
     zen_meta=st.dictionaries(
         st.text(string.ascii_lowercase, min_size=1, max_size=1).map(lambda x: "_" + x),
         st.integers(),
@@ -165,7 +165,7 @@ def test_zen_wrappers_expected_behavior(
     if hasattr(target, "num_decorated"):
         del target.num_decorated
 
-    wrappers, zen_meta = _coordinate_meta_fields_for_interpolation(wrappers, zen_meta)  # type: ignore
+    wrappers, zen_meta = _coordinate_meta_fields_for_interpolation(wrappers, zen_meta)
 
     args = tuple(args)  # type: ignore
     conf = builds(