From 877efa38b41a56b5d53973943a5dfc568a4d3180 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 27 Aug 2026 08:35:18 +0200 Subject: [PATCH 1/2] only rewrites leaves in positive position in `numberize/2` --- lib/elixir/lib/module/types/descr.ex | 43 +++++++++++++++++-- .../test/elixir/module/types/descr_test.exs | 23 ++++++++++ .../test/elixir/module/types/expr_test.exs | 25 +++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 0017204c4f1..a4b5c60a6e7 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -584,7 +584,7 @@ defmodule Module.Types.Descr do defp numberize(:bitmap, bitmap), do: bitmap defp numberize(:map, bdd) do - bdd_map(bdd, fn bdd_leaf(tag, fields) -> + bdd_map_positive(bdd, fn bdd_leaf(tag, fields) -> bdd_leaf_new( tag, fields_map(fn _key, {value, optional?} -> {numberize(value), optional?} end, fields) @@ -593,11 +593,15 @@ defmodule Module.Types.Descr do end defp numberize(:tuple, bdd) do - bdd_map(bdd, fn bdd_leaf(tag, fields) -> bdd_leaf_new(tag, Enum.map(fields, &numberize/1)) end) + bdd_map_positive(bdd, fn bdd_leaf(tag, fields) -> + bdd_leaf_new(tag, Enum.map(fields, &numberize/1)) + end) end defp numberize(:list, bdd) do - bdd_map(bdd, fn bdd_leaf(head, tail) -> bdd_leaf_new(numberize(head), numberize(tail)) end) + bdd_map_positive(bdd, fn bdd_leaf(head, tail) -> + bdd_leaf_new(numberize(head), numberize(tail)) + end) end @doc """ @@ -6167,6 +6171,39 @@ defmodule Module.Types.Descr do end end + # Like `bdd_map/2`, but only rewrites leaves in *positive* position. + # + # `bdd_map/2` is polarity-blind: on `A and not B` it rewrites `B` too, so a + # widening `fun` shrinks the result instead of enlarging it (`numberize/1` + # replacing `not {float()}` by `not {number()}` would drop `{1}` from the + # result even though `{1} == {1.0}`). Whenever `fun` changes a negated leaf, + # we drop the negation altogether: over-approximating is the safe direction + # for widening callers. Negations left untouched by `fun` are preserved, + # so the common negation-free case keeps its exact shape. + defp bdd_map_positive(bdd, fun) do + case bdd do + :bdd_bot -> + :bdd_bot + + :bdd_top -> + :bdd_top + + bdd_leaf(_, _) = leaf -> + fun.(leaf) + + {_, leaf, left, union, right} -> + left = bdd_map_positive(left, fun) + union = bdd_map_positive(union, fun) + right = bdd_map_positive(right, fun) + + case fun.(leaf) do + ^leaf -> bdd_node_new(leaf, left, union, right) + new_leaf when right == :bdd_bot -> bdd_node_new(new_leaf, left, union, :bdd_bot) + new_leaf -> bdd_union(bdd_intersection(new_leaf, left), bdd_union(union, right)) + end + end + end + defp bdd_reduce(bdd, acc, fun) do case bdd do :bdd_bot -> diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 3041600d837..117e3a14c35 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -2737,6 +2737,29 @@ defmodule Module.Types.DescrTest do assert dynamic(list(binary(), float())) |> numberize() == dynamic(list(binary(), number())) end + + test "with negations" do + # Negations must not be widened: `{1}` belongs to `term() and not {float()}` + # and `{1} == {1.0}`, so both must survive numberize. + negated = opt_difference(term(), tuple([float()])) + assert subtype?(tuple([integer()]), numberize(negated)) + assert subtype?(tuple([float()]), numberize(negated)) + + negated = opt_difference(non_empty_list(integer(), atom()), non_empty_list(float(), atom())) + assert subtype?(non_empty_list(integer(), atom()), numberize(negated)) + assert subtype?(non_empty_list(float(), atom()), numberize(negated)) + + negated = opt_difference(open_map(a: {integer(), false}), open_map(a: {float(), false})) + assert subtype?(open_map(a: {integer(), false}), numberize(negated)) + assert subtype?(open_map(a: {float(), false}), numberize(negated)) + + # Negations untouched by numberize are kept as is + type = opt_difference(tuple(), tuple([binary()])) + assert numberize(type) == type + + type = opt_difference(list(integer(), atom()), list(binary(), atom())) + assert numberize(type) == opt_difference(list(number(), atom()), list(binary(), atom())) + end end describe "map_get" do diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 3604340572e..d44e7730944 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -2094,6 +2094,31 @@ defmodule Module.Types.ExprTest do atom([:non_empty_map, :maybe_empty_map]) end + test "does not discard equal values when narrowing against negated types" do + # `other` carries a negation (`... and not {float()}`) from the clause + # subtraction. Widening integers/floats inside that negation would wrongly + # remove `{1}`, even though `{1} == {1.0}` at runtime. + assert typecheck!( + [v, q], + ( + other = + case v do + {a} when is_float(a) -> {:float, a} + other -> other + end + + w = if q, do: {1}, else: {1.5} + + case w do + x when x == other -> {:matched, x} + _ -> :nomatch + end + ) + ) + |> to_quoted_string() == + "dynamic({:matched, {float() or integer()}}) or :nomatch" + end + test "consider external variables as not precise" do assert typecheck!( [x], From ae6700cf830b1cd553b8154ff85e4cce54291c64 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 27 Aug 2026 08:50:33 +0200 Subject: [PATCH 2/2] apply numberize type widening only to map values --- lib/elixir/lib/module/types/descr.ex | 9 ++++++++- .../test/elixir/module/types/descr_test.exs | 11 +++++++++++ .../test/elixir/module/types/expr_test.exs | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index a4b5c60a6e7..059feb83492 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -586,7 +586,7 @@ defmodule Module.Types.Descr do defp numberize(:map, bdd) do bdd_map_positive(bdd, fn bdd_leaf(tag, fields) -> bdd_leaf_new( - tag, + numberize_map_tag(tag), fields_map(fn _key, {value, optional?} -> {numberize(value), optional?} end, fields) ) end) @@ -604,6 +604,13 @@ defmodule Module.Types.Descr do end) end + # Map keys are compared exactly by `==`, only their values coerce, so the + # domain keys are kept as is and only the types they point to are widened. + defp numberize_map_tag(domains) when is_list(domains), + do: fields_map(fn _key, value -> numberize(value) end, domains) + + defp numberize_map_tag(tag), do: tag + @doc """ Returns if the type is a singleton. """ diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 117e3a14c35..d419e94fb8a 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -2738,6 +2738,17 @@ defmodule Module.Types.DescrTest do dynamic(list(binary(), number())) end + test "with domain keys" do + # `==` compares map keys exactly but coerces values, so only the types + # the domain keys point to are widened. + assert closed_map([{domain_key(:integer), integer()}]) |> numberize() == + closed_map([{domain_key(:integer), number()}]) + + assert open_map([{domain_key(:tuple), tuple([float()])}, {:a, {integer(), false}}]) + |> numberize() == + open_map([{domain_key(:tuple), tuple([number()])}, {:a, {number(), false}}]) + end + test "with negations" do # Negations must not be widened: `{1}` belongs to `term() and not {float()}` # and `{1} == {1.0}`, so both must survive numberize. diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index d44e7730944..587e18dd2af 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -2119,6 +2119,24 @@ defmodule Module.Types.ExprTest do "dynamic({:matched, {float() or integer()}}) or :nomatch" end + test "does not discard equal values when narrowing against domain keys" do + # `==` coerces map values, so narrowing `x` from `x == y` must widen the + # `integer() => integer()` domain value to `integer() => number()`. + assert typecheck!( + ( + y = %{1 => 1} + w = %{1 => 1.0} + + case w do + x when x == y and map_size(x) == 1 -> {:eq, x} + _ -> :ne + end + ) + ) + |> to_quoted_string() == + ":ne or {:eq, %{integer() => float()} and not empty_map()}" + end + test "consider external variables as not precise" do assert typecheck!( [x],