diff --git a/effectful/internals/unification.py b/effectful/internals/unification.py index ad022a63..2ad1caa9 100644 --- a/effectful/internals/unification.py +++ b/effectful/internals/unification.py @@ -1058,14 +1058,20 @@ def _(value: collections.abc.Mapping): ktyp = functools.reduce( operator.or_, [nested_type(x).value for x in value.keys()] ) - if ktyp is str: - # str-keyed multi-entry dicts → always TypedDict - fields = {key: nested_type(vl).value for key, vl in value.items()} - return Box(typing.TypedDict("RuntimeTypeDict", fields)) # type: ignore vtyp = functools.reduce( operator.or_, [nested_type(x).value for x in value.values()] ) - if isinstance(ktyp, UnionType) or isinstance(vtyp, UnionType): + if type(value) is dict and ktyp is str and isinstance(vtyp, UnionType): + # str-keyed dicts with *heterogeneous* values → TypedDict, which + # captures the per-field value types that a single ``V`` cannot. + # Homogeneous str-keyed dicts fall through to ``Mapping[str, V]`` + # below: a closed required-key TypedDict is unsound for a runtime + # value (it is really an inhabitant of ``dict[str, V]``), and two + # sibling dicts with different keys would otherwise fail to unify + # against a shared TypeVar (gh #662). + fields = {key: nested_type(vl).value for key, vl in value.items()} + return Box(typing.TypedDict("RuntimeTypeDict", fields)) # type: ignore + elif isinstance(ktyp, UnionType) or isinstance(vtyp, UnionType): return Box(type(value)) else: return Box(canonicalize(type(value))[ktyp, vtyp]) # type: ignore diff --git a/tests/test_internals_unification.py b/tests/test_internals_unification.py index fe3a9ed0..0746e0cd 100644 --- a/tests/test_internals_unification.py +++ b/tests/test_internals_unification.py @@ -831,18 +831,21 @@ class UserTD(typing.TypedDict): assert hints == {"name": str, "age": int} -def test_nested_type_typeddict_homogeneous_str_keys(): - """Multi-key str dicts produce TypedDict even with homogeneous value types.""" +def test_nested_type_homogeneous_str_keys_stays_mapping(): + """Multi-key str dicts with homogeneous values stay Mapping[str, V], not TypedDict. + + A closed required-key TypedDict is unsound for a runtime dict (it is really + an inhabitant of ``dict[str, V]``); inferring one breaks unification of two + sibling dicts against a shared TypeVar (gh #662). TypedDict is reserved for + heterogeneous-valued str dicts, where per-field types carry real information. + """ result = nested_type({"a": 1, "b": 2}).value - assert typing.is_typeddict(result) - hints = typing.get_type_hints(result) - assert hints == {"a": int, "b": int} + assert not typing.is_typeddict(result) + assert canonicalize(result) == canonicalize(dict[str, int]) result = nested_type({"a": {1, 2}, "b": {3, 4}}).value - assert typing.is_typeddict(result) - hints = typing.get_type_hints(result) - assert canonicalize(hints["a"]) == canonicalize(set[int]) - assert canonicalize(hints["b"]) == canonicalize(set[int]) + assert not typing.is_typeddict(result) + assert canonicalize(result) == canonicalize(dict[str, set[int]]) def test_nested_type_non_str_keys_mixed_values_stays_dict(): @@ -2033,3 +2036,24 @@ def g[S, R](x: collections.abc.Callable[[S], R]) -> R: term = g(f) assert typeof(term) is int + + +def test_operation_varargs_homogeneous_dicts_gh662(): + """An op taking ``*args: T`` accepts several homogeneous str-keyed dicts. + + Regression test for gh #662: each dict is inferred as ``Mapping[str, int]`` + rather than a closed required-key TypedDict, so binding the shared ``T`` + against dicts with *different* key sets unifies instead of raising. + """ + from effectful.ops.semantics import typeof + from effectful.ops.types import NotHandled, Operation + + @Operation.define + def f[T](*args: T) -> T: + raise NotHandled + + # Previously raised "Cannot unify TypedDict ...: required field 'z' ...". + term = f({"x": 0, "z": 3}, {"y": 1, "x": 2}) + typ = typeof(term) + assert issubclass(typ, collections.abc.MutableMapping) + assert not typing.is_typeddict(typ)