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

Issue:
Detecting need for auto type-widening in case of interpolation field
hydra-zen reduces common sources of friction with Hydra's runtime type-checking; this includes [applying automatic type-widening to accommodate Builds-as-default](https://github.com/mit-ll-responsible-ai/hydra-zen/pull/84)

In cases where the interpolated field is resolved to be a `Builds`-like object, Hydra can raise a validation error -- even if, post-instantiation, the type-annotation is ultimately respected.

For instance, consider:

```python
from typing import List
from hydra_zen import builds, instantiate, launch, make_config

def test(data: List[float]):
    return data

def get_data():
    return [5.0, 2.0]

cfg = builds(test, data="${data}")
Config = make_config(data=builds(get_data), task=cfg)
```

```python
>>> instantiate(Config)
---------------------------------------------------------------------------
ValidationError: Invalid value assigned: dict is not a ListConfig, list or tuple.
```

But if we simply eliminate the annotation for `def test(data: List[float]):`, everything is OK:

```python

def test(data):
    return data

cfg = builds(test, data="${data}")
Config = make_config(data=builds(get_data), task=cfg)
```
```python
>>> instantiate(Config)
{'data': [5.0, 2.0], 'task': [5.0, 2.0]}
```

Thu, we should expand the conditions under which we apply type widening to include cases where a specified config value is an interpolated field.

Discussion:

Patch:
diff --git a/docs/source/how_to/pytorch_lightning.rst b/docs/source/how_to/pytorch_lightning.rst
index 2bff5394..cc7f2e2c 100644
--- a/docs/source/how_to/pytorch_lightning.rst
+++ b/docs/source/how_to/pytorch_lightning.rst
@@ -51,7 +51,7 @@ following code. Here, we define our single-layer neural network and the `lightni
    import torch as tr
    import torch.nn as nn
    import torch.nn.functional as F
-   import torch.optim as optim
+   from torch.optim import Optimizer
    from torch.utils.data import DataLoader, TensorDataset
    
    from hydra_zen.typing import Partial
@@ -70,7 +70,7 @@ following code. Here, we define our single-layer neural network and the `lightni
        def __init__(
            self,
            model: nn.Module,
-           optim: Partial[optim.Adam],
+           optim: Partial[Optimizer],
            dataloader: Type[DataLoader],
            target_fn: Callable[[tr.Tensor], tr.Tensor],
            training_domain: tr.Tensor,
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index 210413bd..3182576b 100644
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -128,6 +128,32 @@ def _get_target(x):
     return getattr(x, _TARGET_FIELD_NAME)
 
 
+def _retain_type_info(type_: type, value: Any, hydra_recursive: Optional[bool]):
+    # OmegaConf's type-checking occurs before instantiation occurs.
+    # This means that, e.g., passing `Builds[int]` to a field `x: int`
+    # will fail Hydra's type-checking upon instantiation, even though
+    # the recursive instantiation will appropriately produce `int` for
+    # that field. This will not be addressed by hydra/omegaconf:
+    #    https://github.com/facebookresearch/hydra/issues/1759
+    # Thus we will auto-broaden the annotation when we see that a field
+    # is set with a structured config as a default value - assuming that
+    # the field isn't annotated with a structured config type.
+
+    # Each condition is included separately to ensure that our tests
+    # cover all scenarios
+    if hydra_recursive is False:
+        return True
+    elif not is_builds(value):
+        if _utils.is_interpolated_string(value):
+            # an interpolated field may resolve to a structured conf, which may
+            # instantiate to a value of the specified type
+            return False
+        return True
+    elif is_builds(type_):
+        return True
+    return False
+
+
 def _target_as_kwarg_deprecation(func: _T2) -> Callable[..., _T2]:
     @wraps(func)
     def wrapped(*args, **kwargs):
@@ -1702,18 +1728,9 @@ def builds(
             sanitized_value = getattr(_field, "default", value)
             sanitized_type = (
                 _utils.sanitized_type(type_, wrap_optional=sanitized_value is None)
-                # OmegaConf's type-checking occurs before instantiation occurs.
-                # This means that, e.g., passing `Builds[int]` to a field `x: int`
-                # will fail Hydra's type-checking upon instantiation, even though
-                # the recursive instantiation will appropriately produce `int` for
-                # that field. This will not be addressed by hydra/omegaconf:
-                #    https://github.com/facebookresearch/hydra/issues/1759
-                # Thus we will auto-broaden the annotation when we see that a field
-                # is set with a structured config as a default value - assuming that
-                # the field isn't annotated with a structured config type.
-                if hydra_recursive is False
-                or not is_builds(sanitized_value)
-                or is_builds(type_)
+                if _retain_type_info(
+                    type_=type_, value=sanitized_value, hydra_recursive=hydra_recursive
+                )
                 else Any
             )
             sanitized_base_fields.append((name, sanitized_type, _field))
@@ -2419,9 +2436,11 @@ def make_config(
                     # f.default: Field
                     # f.default.default: Any
                     f.hint
-                    if hydra_recursive is False
-                    or not is_builds(f.default.default)  # type: ignore
-                    or is_builds(f.hint)
+                    if _retain_type_info(
+                        type_=f.hint,
+                        value=f.default.default,  # type: ignore
+                        hydra_recursive=hydra_recursive,
+                    )
                     else Any
                 ),
                 f.default,


Test patch:
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
index c6104e25..4e6f14b3 100644
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -25,6 +25,7 @@ from hypothesis import given, note, settings
 from omegaconf.errors import ValidationError
 
 from hydra_zen import (
+    ZenField,
     builds,
     hydrated_dataclass,
     instantiate,
@@ -411,7 +412,31 @@ def test_type_widening_with_internal_conversion_to_Builds():
     # via type-sanitization.
     Base = make_config(x=1)
     Conf = builds(func_with_list_annotation, x=[1, 2], builds_bases=(Base,))
-    instantiate(Conf)
+    instantiate(Conf)  # shouldn't raise
+
+
+def test_type_widening_for_interpolated_field():
+    C1 = make_config(x=ZenField(str, "A"))
+    assert get_type_hints(C1)["x"] is str
+
+    # field is interpolated entry
+    C2 = make_config(x=ZenField(str, "${A}"))
+    assert get_type_hints(C2)["x"] is Any
+
+
+def use_data(data: List[float]):
+    return data
+
+
+def get_data():
+    return [5.0, 2.0]
+
+
+def test_type_widening_for_interpolated_field_regression_example():
+    cfg = builds(use_data, data="${data}")
+    Config = make_config(data=builds(get_data), out=cfg)
+    obj = instantiate(Config)  # shouldn't raise
+    assert obj.out == get_data()
 
 
 def func_with_various_defaults(x=1, y="a", z=[1, 2]):