From 0494e210e8b593522f2f52d0a5db1691c9a1de18 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc Date: Sun, 25 May 2025 20:02:38 +0200 Subject: [PATCH 1/2] Add tuple_elim_negations function; refactor tuple_reduce --- lib/elixir/lib/module/types/descr.ex | 129 +++++++++++----- .../test/elixir/module/types/descr_test.exs | 141 ++++-------------- 2 files changed, 125 insertions(+), 145 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index c9506403e6a..2057395a1ac 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -3118,75 +3118,117 @@ defmodule Module.Types.Descr do end defp process_tuples_values(dnf) do - Enum.reduce(dnf, none(), fn {tag, elements, negs}, acc -> - union(tuple_values(tag, elements, negs), acc) + tuple_reduce(dnf, none(), &union/2, fn tag, elements -> + cond do + Enum.any?(elements, &empty?/1) -> none() + tag == :open -> term() + tag == :closed -> Enum.reduce(elements, none(), &union/2) + end end) end - defp tuple_values(tag, elements, []) do - cond do - Enum.any?(elements, &empty?/1) -> none() - tag == :open -> term() - tag == :closed -> Enum.reduce(elements, none(), &union/2) - end + defp tuple_reduce(dnf, initial, join, transform) do + Enum.reduce(dnf, initial, fn {tag, elements, negs}, acc -> + join.(acc, tuple_reduce(tag, elements, negs, initial, join, transform)) + end) end - defp tuple_values(_tag, _elements, [{:open, []} | _]), do: none() + defp tuple_reduce(tag, elements, [], _init, _join, transform), do: transform.(tag, elements) + defp tuple_reduce(_tag, _elements, [{:open, []} | _], initial, _join, _transform), do: initial - defp tuple_values(tag, elements, [{neg_tag, neg_elements} | negs]) do + defp tuple_reduce(tag, elements, [{neg_tag, neg_elements} | negs], initial, join, transform) do n = length(elements) m = length(neg_elements) if (tag == :closed and n < m) or (neg_tag == :closed and n > m) do - tuple_values(tag, elements, negs) + tuple_reduce(tag, elements, negs, initial, join, transform) else # Those two functions eliminate the negations, transforming into # a union of tuples to compute their values. - values_elements([], tag, elements, neg_elements, negs) - |> union(values_size(n, m, tag, elements, neg_tag, negs)) + elim_content([], tag, elements, neg_elements, negs, initial, join, transform) + |> join.(elim_size(n, m, tag, elements, neg_tag, negs, initial, join, transform)) end end + # Eliminates negations according to tuple content. # This means that there are no more neg_elements to subtract -- end the recursion. - defp values_elements(_acc, _tag, _elements, [], _), do: none() + defp elim_content(_acc, _tag, _elements, [], _, initial, _join, _transform), do: initial - # Eliminates negations according to tuple content. # Subtracts each element of a negative tuple to build a new tuple with the difference. # Example: {number(), atom()} and not {float(), :foo} contains types {integer(), :foo} # as well as {float(), atom() and not :foo} # Same process as tuple_elements_empty? - defp values_elements(acc, tag, elements, [neg_type | neg_elements], negs) do + defp elim_content(acc, tag, elements, [neg_type | neg_elements], negs, init, join, transform) do {ty, elements} = List.pop_at(elements, 0, term()) diff = difference(ty, neg_type) if empty?(diff) do - none() + init else - tuple_values(tag, Enum.reverse(acc, [diff | elements]), negs) + tuple_reduce(tag, Enum.reverse(acc, [diff | elements]), negs, init, join, transform) end - |> union(values_elements([ty | acc], tag, elements, neg_elements, negs)) + |> join.(elim_content([ty | acc], tag, elements, neg_elements, negs, init, join, transform)) end # Eliminates negations according to size # Example: {integer(), ...} and not {term(), term(), ...} contains {integer()} - defp values_size(n, m, tag, elements, neg_tag, negs) do - if tag == :closed do - none() - else - n..(m - 1)//1 - |> Enum.reduce(none(), fn i, acc -> - tuple_values(:closed, tuple_fill(elements, i), negs) |> union(acc) - end) - |> union( - if neg_tag == :open do - none() - else - tuple_values(tag, tuple_fill(elements, m + 1), negs) - end - ) + defp elim_size(_, _, :closed, _, _, _, initial, _join, _transfo), do: initial + + defp elim_size(n, m, tag, elements, neg_tag, negs, initial, join, transform) do + n..(m - 1)//1 + |> Enum.reduce(initial, fn i, acc -> + tuple_reduce(:closed, tuple_fill(elements, i), negs, initial, join, transform) + |> join.(acc) + end) + |> join.( + if neg_tag == :open do + initial + else + tuple_reduce(tag, tuple_fill(elements, m + 1), negs, initial, join, transform) + end + ) + end + + @doc """ + Converts a tuple type to a simple union by eliminating negations. + + Takes a tuple type with complex negations and simplifies it to a union of + positive tuple literals only. + + For static tuple types: eliminates all negations from the DNF representation. + For gradual tuple types: processes both dynamic and static components separately, + then combines them. + + Uses `tuple_reduce/4` with concatenation as the join function and a transform + that is simply the identity. + + Returns the descriptor unchanged for non-tuple types, or a descriptor with + simplified tuple DNF containing only positive literals. If simplification + results in an empty tuple list, removes the `:tuple` key entirely. + """ + def tuple_elim_negations(descr) do + case :maps.take(:dynamic, descr) do + :error -> + tuple_elim_negations_static(descr) + + {dynamic, static} -> + tuple_elim_negations_static(static) + |> union(dynamic(tuple_elim_negations_static(dynamic))) + end + end + + # Call tuple_reduce to build the simple union of tuples that come from each map literal. + # Thus, initial is `[]`, join is concatenation, and the transform of a map literal + # with no negations is just to keep the map literal as is. + defp tuple_elim_negations_static(%{tuple: dnf} = descr) do + case tuple_reduce(dnf, [], &Kernel.++/2, fn tag, elements -> [{tag, elements, []}] end) do + [] -> Map.delete(descr, :tuple) + new_dnf -> %{descr | tuple: new_dnf} end end + defp tuple_elim_negations_static(descr), do: descr + defp tuple_pop_index(tag, elements, index) do case List.pop_at(elements, index) do {nil, _} -> {tag_to_type(tag), %{tuple: [{tag, elements, []}]}} @@ -3210,6 +3252,25 @@ defmodule Module.Types.Descr do end end + # defp tuple_eliminate_negations(dnf) do + # Enum.flat_map(dnf, [], fn + # {tag, elements, []} -> [{tag, elements, []}] + + # {tag, elements, negs} -> + # # for each negation, we reduce it on {tag, elements} which produces a union of {tag, elements} + # # then we take this list and remove the next negation from it + # Enum.reduce(negs, [{tag, elements, []}], fn + # {neg_tag, neg_elements}, acc -> + + # Enum.flat_map(acc, fn {tag, elements, negs} -> + # Enum.map(neg_elements, fn neg_element -> + # {tag, elements, [{neg_tag, neg_element} | negs]} + # end) + # end) + # end) + # end) + # end + @doc """ Delete an element from the tuple. diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 7747a6bb748..0ac095b7c76 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1193,6 +1193,36 @@ defmodule Module.Types.DescrTest do |> equal?(integer()) end + test "tuple_elim_negations" do + # take complex tuples, normalize them, and check if they are still equal + complex_tuples = [ + tuple([term(), atom(), number()]) + |> difference(tuple([atom(), atom(), float()])), + # overlapping union and difference producing multiple variants + difference( + tuple([union(atom(), pid()), union(integer(), float())]), + tuple([union(atom(), pid()), float()]) + ), + # open_tuple case with union in elements + difference( + open_tuple([union(boolean(), pid()), union(atom(), integer())]), + open_tuple([pid(), integer()]) + ), + open_tuple([term(), term(), term()]) + |> difference(open_tuple([term(), integer(), atom(), atom()])) + |> difference(tuple([float(), float(), float(), float(), float()])) + |> difference(tuple([term(), term(), term(), term(), term(), term()])) + ] + + Enum.each(complex_tuples, fn orig -> + norm = tuple_elim_negations(orig) + # should split into multiple simple tuples + assert equal?(norm, orig) + assert Enum.all?(norm.tuple, fn {_, _, neg} -> neg == [] end) + assert not Enum.all?(orig.tuple, fn {_, _, neg} -> neg == [] end) + end) + end + test "map_fetch" do assert map_fetch(term(), :a) == :badmap assert map_fetch(union(open_map(), integer()), :a) == :badmap @@ -1798,116 +1828,5 @@ defmodule Module.Types.DescrTest do |> difference(open_map(a: integer(), b: atom(), c: union(pid(), port()))) |> to_quoted_string() == "%{..., a: float(), b: atom(), c: port()}" end - - test "structs" do - assert open_map(__struct__: atom([URI])) |> to_quoted_string() == - "%{..., __struct__: URI}" - - assert closed_map(__struct__: atom([URI])) |> to_quoted_string() == - "%{__struct__: URI}" - - assert closed_map(__struct__: atom([URI, Another])) |> to_quoted_string() == - "%{__struct__: Another or URI}" - - assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: term()) - |> to_quoted_string(collapse_structs: false) == - "%Decimal{sign: term(), coef: term(), exp: term()}" - - assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: term()) - |> to_quoted_string() == - "%Decimal{}" - - assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: integer()) - |> to_quoted_string() == - "%Decimal{sign: integer()}" - - # Does not fuse structs - assert union(closed_map(__struct__: atom([Foo])), closed_map(__struct__: atom([Bar]))) - |> to_quoted_string() == - "%{__struct__: Bar} or %{__struct__: Foo}" - - # Properly format non_struct_map - assert open_map(__struct__: if_set(negation(atom()))) |> to_quoted_string() == - "non_struct_map()" - end - end - - describe "performance" do - test "tuple difference" do - # Large difference with no duplicates - descr1 = - union( - atom([:ignored, :reset]), - tuple([atom([:font_style]), atom([:italic])]) - ) - - descr2 = - union( - atom([:ignored, :reset]), - union( - tuple([atom([:font_style]), atom([:italic])]), - Enum.reduce( - for elem1 <- 1..5, elem2 <- 1..5 do - tuple([atom([:"f#{elem1}"]), atom([:"s#{elem2}"])]) - end, - &union/2 - ) - ) - ) - - assert subtype?(descr1, descr2) - refute subtype?(descr2, descr1) - end - - test "map difference" do - # Create a large map with various types - map1 = - open_map([ - {:id, integer()}, - {:name, binary()}, - {:age, union(integer(), atom())}, - {:email, binary()}, - {:active, boolean()}, - {:tags, list(atom())} - ]) - - # Create another large map with some differences and many more entries - map2 = - open_map( - [ - {:id, integer()}, - {:name, binary()}, - {:age, integer()}, - {:email, binary()}, - {:active, boolean()}, - {:tags, non_empty_list(atom())}, - {:meta, - open_map([ - {:created_at, binary()}, - {:updated_at, binary()}, - {:status, atom()} - ])}, - {:permissions, tuple([atom(), integer(), atom()])}, - {:profile, - open_map([ - {:bio, binary()}, - {:interests, non_empty_list(binary())}, - {:social_media, - open_map([ - {:twitter, binary()}, - {:instagram, binary()}, - {:linkedin, binary()} - ])} - ])}, - {:notifications, boolean()} - ] ++ - Enum.map(1..50, fn i -> - {:"field_#{i}", atom([:"value_#{i}"])} - end) - ) - - refute subtype?(map1, map2) - assert subtype?(map2, map1) - end end end From 40a9acc58b92434da99a542fc269ae424cbc8d23 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc Date: Mon, 26 May 2025 16:35:14 +0200 Subject: [PATCH 2/2] Fix PR --- lib/elixir/lib/module/types/descr.ex | 19 --- .../test/elixir/module/types/descr_test.exs | 111 ++++++++++++++++++ 2 files changed, 111 insertions(+), 19 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 2057395a1ac..5ca5955665c 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -3252,25 +3252,6 @@ defmodule Module.Types.Descr do end end - # defp tuple_eliminate_negations(dnf) do - # Enum.flat_map(dnf, [], fn - # {tag, elements, []} -> [{tag, elements, []}] - - # {tag, elements, negs} -> - # # for each negation, we reduce it on {tag, elements} which produces a union of {tag, elements} - # # then we take this list and remove the next negation from it - # Enum.reduce(negs, [{tag, elements, []}], fn - # {neg_tag, neg_elements}, acc -> - - # Enum.flat_map(acc, fn {tag, elements, negs} -> - # Enum.map(neg_elements, fn neg_element -> - # {tag, elements, [{neg_tag, neg_element} | negs]} - # end) - # end) - # end) - # end) - # end - @doc """ Delete an element from the tuple. diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 0ac095b7c76..3d3ed3b5871 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1828,5 +1828,116 @@ defmodule Module.Types.DescrTest do |> difference(open_map(a: integer(), b: atom(), c: union(pid(), port()))) |> to_quoted_string() == "%{..., a: float(), b: atom(), c: port()}" end + + test "structs" do + assert open_map(__struct__: atom([URI])) |> to_quoted_string() == + "%{..., __struct__: URI}" + + assert closed_map(__struct__: atom([URI])) |> to_quoted_string() == + "%{__struct__: URI}" + + assert closed_map(__struct__: atom([URI, Another])) |> to_quoted_string() == + "%{__struct__: Another or URI}" + + assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: term()) + |> to_quoted_string(collapse_structs: false) == + "%Decimal{sign: term(), coef: term(), exp: term()}" + + assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: term()) + |> to_quoted_string() == + "%Decimal{}" + + assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: integer()) + |> to_quoted_string() == + "%Decimal{sign: integer()}" + + # Does not fuse structs + assert union(closed_map(__struct__: atom([Foo])), closed_map(__struct__: atom([Bar]))) + |> to_quoted_string() == + "%{__struct__: Bar} or %{__struct__: Foo}" + + # Properly format non_struct_map + assert open_map(__struct__: if_set(negation(atom()))) |> to_quoted_string() == + "non_struct_map()" + end + end + + describe "performance" do + test "tuple difference" do + # Large difference with no duplicates + descr1 = + union( + atom([:ignored, :reset]), + tuple([atom([:font_style]), atom([:italic])]) + ) + + descr2 = + union( + atom([:ignored, :reset]), + union( + tuple([atom([:font_style]), atom([:italic])]), + Enum.reduce( + for elem1 <- 1..5, elem2 <- 1..5 do + tuple([atom([:"f#{elem1}"]), atom([:"s#{elem2}"])]) + end, + &union/2 + ) + ) + ) + + assert subtype?(descr1, descr2) + refute subtype?(descr2, descr1) + end + + test "map difference" do + # Create a large map with various types + map1 = + open_map([ + {:id, integer()}, + {:name, binary()}, + {:age, union(integer(), atom())}, + {:email, binary()}, + {:active, boolean()}, + {:tags, list(atom())} + ]) + + # Create another large map with some differences and many more entries + map2 = + open_map( + [ + {:id, integer()}, + {:name, binary()}, + {:age, integer()}, + {:email, binary()}, + {:active, boolean()}, + {:tags, non_empty_list(atom())}, + {:meta, + open_map([ + {:created_at, binary()}, + {:updated_at, binary()}, + {:status, atom()} + ])}, + {:permissions, tuple([atom(), integer(), atom()])}, + {:profile, + open_map([ + {:bio, binary()}, + {:interests, non_empty_list(binary())}, + {:social_media, + open_map([ + {:twitter, binary()}, + {:instagram, binary()}, + {:linkedin, binary()} + ])} + ])}, + {:notifications, boolean()} + ] ++ + Enum.map(1..50, fn i -> + {:"field_#{i}", atom([:"value_#{i}"])} + end) + ) + + refute subtype?(map1, map2) + assert subtype?(map2, map1) + end end end