From 98e1502632d0c5d1d468b6b1d94e0ad59bc80419 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 12 May 2026 15:16:06 +0200 Subject: [PATCH 1/5] Add two important map projection optimization paths Add the two important optimization paths for negative-map projection: value-side projection for map fetch/get and shape-side projection for map put/update. Keep regression coverage for projected negative maps. --- lib/elixir/lib/module/types/descr.ex | 135 +++++++++++++++--- .../test/elixir/module/types/descr_test.exs | 52 +++++++ .../test/elixir/module/types/map_test.exs | 16 +++ 3 files changed, 182 insertions(+), 21 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 16eac25cade..88a04fc66f9 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -3394,9 +3394,22 @@ defmodule Module.Types.Descr do {tag, fields, negs}, acc -> {value, bdd} = map_pop_key_bdd(tag, fields, key) - negs - |> map_split_negative_key(key, value, bdd) - |> Enum.reduce(acc, fn {value, _}, acc -> union(value, acc) end) + case map_split_negative_pairs_key(negs, key) do + :empty -> + acc + + negative -> + value = + if map_pair_projection_keeps_full_fst?(negative, bdd) do + value + else + negs + |> map_split_negative_key(key, value, bdd) + |> Enum.reduce(none(), fn {value, _}, acc -> union(value, acc) end) + end + + union(value, acc) + end end) catch :open -> {true, term()} @@ -3405,6 +3418,50 @@ defmodule Module.Types.Descr do pop_optional_static(value) end + defp map_split_negative_pairs_key(negs, key) do + Enum.reduce_while(negs, [], fn + {:open, empty}, _acc when is_fields_empty(empty) -> + {:halt, :empty} + + {tag, fields}, neg_acc -> + {:cont, [map_pop_key_bdd(tag, fields, key) | neg_acc]} + end) + end + + defp map_split_negative_pairs_domain(negs, domain_key) do + Enum.reduce_while(negs, [], fn + {:open, empty}, _acc when is_fields_empty(empty) -> + {:halt, :empty} + + {tag, fields}, neg_acc -> + {_found?, value, bdd} = map_pop_domain_bdd(tag, fields, domain_key) + {:cont, [{value, bdd} | neg_acc]} + end) + end + + # Projection shortcuts for the pair-shaped map split below. These are + # existential checks: if at least one remaining-map sample avoids all negative + # remaining maps, the full key-value side survives; dually, if at least one + # key-value sample avoids all negative key values, the full map-shape side + # survives. If neither shortcut applies, we fall back to the regular split. + defp map_pair_projection_keeps_full_fst?(negative, bdd) do + neg_bdd = + Enum.reduce(negative, :bdd_bot, fn {_neg_value, neg_bdd}, acc -> + map_union(neg_bdd, acc) + end) + + not map_empty?(map_difference(bdd, neg_bdd)) + end + + defp map_pair_projection_keeps_full_snd?(negative, value) do + neg_values = + Enum.reduce(negative, none(), fn {neg_value, _neg_bdd}, acc -> + union(neg_value, acc) + end) + + not empty?(difference(value, neg_values)) + end + defp map_split_negative_key(negs, key, value, bdd) do map_split_negative(negs, value, bdd, fn neg_tag, neg_fields -> case fields_take(key, neg_fields) do @@ -3443,19 +3500,17 @@ defmodule Module.Types.Descr do if neg_tag == :closed and map_empty?(map_intersection(bdd, neg_bdd)) do [{value, bdd} | acc] else - intersection_value = intersection(value, neg_value) + diff_bdd = map_difference(bdd, neg_bdd) - if empty?(intersection_value) do - [{value, bdd} | acc] - else - diff_bdd = map_difference(bdd, neg_bdd) + cond do + value == neg_value or subtype?(value, neg_value) -> + if map_empty?(diff_bdd), do: acc, else: [{value, diff_bdd} | acc] - if map_empty?(diff_bdd) do + map_empty?(diff_bdd) -> prepend_pair_unless_empty_diff(value, neg_value, bdd, acc) - else - acc = [{intersection_value, diff_bdd} | acc] - prepend_pair_unless_empty_diff(value, neg_value, bdd, acc) - end + + true -> + prepend_pair_unless_empty_diff(value, neg_value, bdd, [{value, diff_bdd} | acc]) end end end) @@ -3861,10 +3916,35 @@ defmodule Module.Types.Descr do {tag, fields, negs}, {value, bdd} -> {fst, snd} = map_pop_key_bdd(tag, fields, key) - pairs = map_split_negative_key(negs, key, fst, snd) - {maybe_union(value, fn -> Enum.reduce(pairs, none(), &union(elem(&1, 0), &2)) end), - Enum.reduce(pairs, bdd, &map_union(elem(&1, 1), &2))} + case map_split_negative_pairs_key(negs, key) do + :empty -> + {value, bdd} + + negative -> + keep_fst? = + value == nil or map_pair_projection_keeps_full_fst?(negative, snd) + + keep_snd? = map_pair_projection_keeps_full_snd?(negative, fst) + + pairs = + if keep_fst? and keep_snd?, + do: [], + else: map_split_negative_key(negs, key, fst, snd) + + {maybe_union(value, fn -> + if keep_fst? do + fst + else + Enum.reduce(pairs, none(), &union(elem(&1, 0), &2)) + end + end), + if keep_snd? do + map_union(bdd, snd) + else + Enum.reduce(pairs, bdd, &map_union(elem(&1, 1), &2)) + end} + end end) if bdd == :bdd_bot do @@ -4197,11 +4277,24 @@ defmodule Module.Types.Descr do {tag_or_domains, fields, negs}, acc -> {_found, value, bdd} = map_pop_domain_bdd(tag_or_domains, fields, domain_key) - negs - |> map_split_negative(value, bdd, fn neg_tag, neg_fields -> - map_pop_domain_bdd(neg_tag, neg_fields, domain_key) - end) - |> Enum.reduce(acc, fn {value, _}, acc -> union(value, acc) end) + case map_split_negative_pairs_domain(negs, domain_key) do + :empty -> + acc + + negative -> + value = + if map_pair_projection_keeps_full_fst?(negative, bdd) do + value + else + negs + |> map_split_negative(value, bdd, fn neg_tag, neg_fields -> + map_pop_domain_bdd(neg_tag, neg_fields, domain_key) + end) + |> Enum.reduce(none(), fn {value, _}, acc -> union(value, acc) end) + end + + union(value, acc) + end end) end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index f14c5056995..f52dc42dee6 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -24,6 +24,18 @@ defmodule Module.Types.DescrTest do defp list(elem_type, tail_type), do: union(empty_list(), non_empty_list(elem_type, tail_type)) defp map_with_default(descr), do: open_map([{to_domain_keys(:term), descr}]) + defp projected_negative_map(size) do + Enum.reduce(1..size, open_map(k: open_map(), x: term()), fn index, acc -> + difference( + acc, + open_map([ + {:k, open_map([{:"value#{index}", integer()}])}, + {:"field#{index}", integer()} + ]) + ) + end) + end + describe "union" do test "bitmap" do assert union(integer(), float()) == union(float(), integer()) @@ -2071,6 +2083,11 @@ defmodule Module.Types.DescrTest do |> map_fetch_key(:a) == {false, integer()} end + # Times out without a projection-only map_fetch_key path + test "map_fetch_key with projected negative maps" do + assert map_fetch_key(projected_negative_map(100), :k) == {false, open_map()} + end + test "map_fetch_key with dynamic" do assert map_fetch_key(dynamic(), :a) == {true, dynamic()} assert map_fetch_key(union(dynamic(), integer()), :a) == :badmap @@ -2252,6 +2269,11 @@ defmodule Module.Types.DescrTest do assert map_get(map, list(integer())) == {:ok, atom([:empty, :non_empty])} end + + # Times out without a projection-only map_get path + test "with projected negative maps" do + assert map_get(projected_negative_map(100), atom([:k])) == {:ok, open_map()} + end end describe "map_update" do @@ -2337,6 +2359,12 @@ defmodule Module.Types.DescrTest do |> map_update(atom([:b]), integer(), true, true) == {none(), none(), []} end + # Times out without a projection-aware map_update path + test "with projected negative maps" do + assert map_update(projected_negative_map(100), atom([:k]), binary()) == + {open_map(), open_map(k: binary(), x: term()), []} + end + test "with non-empty open maps does not call the callback with none from absent branches" do # This is a test of the map_update_fun/5 with forced?: false parameter. # We check that it does not call its typed_fun argument with `none()` @@ -2802,6 +2830,30 @@ defmodule Module.Types.DescrTest do ]) )} end + + # Times out without proper map_put + test "with projected negative maps" do + map = projected_negative_map(100) + + assert map_put(map, atom([:k]), binary()) == {:ok, open_map(k: binary(), x: term())} + + map = difference(open_map(k: integer(), x: term()), open_map(k: integer(), a: integer())) + + assert map_put(map, atom([:k]), binary()) == + {:ok, + difference(open_map(k: binary(), x: term()), open_map(k: binary(), a: integer()))} + end + + test "with projected negative maps and no popped value projection" do + # map_put/3 passes nil as the popped value accumulator because it only + # needs the map side. The final negative covers the whole popped map shape + # but not the key value, so this exercises that nil shortcut directly. + map = + projected_negative_map(100) + |> difference(open_map(k: atom(), x: term())) + + assert map_put(map, atom([:k]), binary()) == {:ok, open_map(k: binary(), x: term())} + end end describe "disjoint" do diff --git a/lib/elixir/test/elixir/module/types/map_test.exs b/lib/elixir/test/elixir/module/types/map_test.exs index 106a5ecf8c7..52522425729 100644 --- a/lib/elixir/test/elixir/module/types/map_test.exs +++ b/lib/elixir/test/elixir/module/types/map_test.exs @@ -833,6 +833,22 @@ defmodule Module.Types.MapTest do assert typeerror!([x = []], Map.put(x, :key, :value)) =~ "incompatible types given to Map.put/3" end + + test "errors with dynamic key and value" do + assert typeerror!([key, value], Map.put(1, key, value)) |> strip_ansi() =~ """ + incompatible types given to Map.put/3: + + Map.put(1, key, value) + + given types: + + integer(), dynamic(), dynamic() + + but expected one of: + + map(), term(), term() + """ + end end describe "Map.put_new_lazy/3" do From c46b3cb3656fe4acc1422e5794199ebb499d124d Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 12 May 2026 17:17:15 +0200 Subject: [PATCH 2/5] Fix pop domain --- lib/elixir/lib/module/types/descr.ex | 54 +++++++++---------- .../test/elixir/module/types/descr_test.exs | 49 +++++++++++++++++ 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 88a04fc66f9..6873f81ceef 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -3428,17 +3428,6 @@ defmodule Module.Types.Descr do end) end - defp map_split_negative_pairs_domain(negs, domain_key) do - Enum.reduce_while(negs, [], fn - {:open, empty}, _acc when is_fields_empty(empty) -> - {:halt, :empty} - - {tag, fields}, neg_acc -> - {_found?, value, bdd} = map_pop_domain_bdd(tag, fields, domain_key) - {:cont, [{value, bdd} | neg_acc]} - end) - end - # Projection shortcuts for the pair-shaped map split below. These are # existential checks: if at least one remaining-map sample avoids all negative # remaining maps, the full key-value side survives; dually, if at least one @@ -4275,26 +4264,31 @@ defmodule Module.Types.Descr do map_domain_tag_to_type(tag, domain_key) |> union(acc) {tag_or_domains, fields, negs}, acc -> - {_found, value, bdd} = map_pop_domain_bdd(tag_or_domains, fields, domain_key) - - case map_split_negative_pairs_domain(negs, domain_key) do - :empty -> - acc - - negative -> - value = - if map_pair_projection_keeps_full_fst?(negative, bdd) do - value - else - negs - |> map_split_negative(value, bdd, fn neg_tag, neg_fields -> - map_pop_domain_bdd(neg_tag, neg_fields, domain_key) - end) - |> Enum.reduce(none(), fn {value, _}, acc -> union(value, acc) end) - end - - union(value, acc) + if init_map_line_empty?(tag_or_domains, fields, negs) do + acc + else + {_found, value, _bdd} = map_pop_domain_bdd(tag_or_domains, fields, domain_key) + union(value, acc) end + + # case map_split_negative_pairs_domain(negs, domain_key) do + # :empty -> + # acc + + # negative -> + # value = + # if map_pair_projection_keeps_full_fst?(negative, bdd) do + # value + # else + # negs + # |> map_split_negative(value, bdd, fn neg_tag, neg_fields -> + # map_pop_domain_bdd(neg_tag, neg_fields, domain_key) + # end) + # |> Enum.reduce(none(), fn {value, _}, acc -> union(value, acc) end) + # end + + # union(value, acc) + # end end) end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index f52dc42dee6..31de6056d59 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -2241,6 +2241,55 @@ defmodule Module.Types.DescrTest do map = closed_map([{:a, atom([:a])}, {:__struct__, term()}, {domain_key(:atom), pid()}]) {:ok, term} = map_get(map, atom() |> difference(atom([:a]))) assert equal?(term, term()) + + base = open_map([{domain_key(:atom), term()}]) + bad = open_map(a: if_set(negation(integer()))) + map = negation(union(negation(base), bad)) + + assert equal?(map, open_map(a: integer())) + + {:ok, type} = map_get(map, atom()) + assert equal?(type, term()) + + {:ok, type} = map_get(map, atom([:a])) + assert equal?(type, integer()) + + map = closed_map([{:a, term()}, {domain_key(:atom), integer()}]) + + {:ok, type} = map_get(map, atom()) + assert equal?(type, term()) + + {:ok, type} = map_get(map, atom([:a])) + assert equal?(type, term()) + + {:ok, type} = map_get(map, difference(atom(), atom([:a]))) + assert equal?(type, integer()) + + map = + closed_map([{:a, term()}, {domain_key(:atom), integer()}]) + |> difference(open_map(a: negation(pid()))) + + {:ok, type} = map_get(map, atom()) + assert equal?(type, union(integer(), pid())) + + {:ok, type} = map_get(map, atom([:a])) + assert equal?(type, pid()) + + {:ok, type} = map_get(map, difference(atom(), atom([:a]))) + assert equal?(type, integer()) + + map = + closed_map([{:a, term()}, {:b, binary()}, {domain_key(:atom), integer()}]) + |> difference(open_map(a: negation(pid()))) + + {:ok, type} = map_get(map, atom()) + assert equal?(type, union(union(integer(), pid()), binary())) + + {:ok, type} = map_get(map, atom([:a, :b])) + assert equal?(type, union(pid(), binary())) + + {:ok, type} = map_get(map, difference(atom(), atom([:a, :b]))) + assert equal?(type, integer()) end test "with lists" do From f3d8b02c073f5033da96f110e82072c12cfcee10 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 12 May 2026 17:19:40 +0200 Subject: [PATCH 3/5] remove comments --- lib/elixir/lib/module/types/descr.ex | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 6873f81ceef..9d5042c22f9 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -4270,25 +4270,6 @@ defmodule Module.Types.Descr do {_found, value, _bdd} = map_pop_domain_bdd(tag_or_domains, fields, domain_key) union(value, acc) end - - # case map_split_negative_pairs_domain(negs, domain_key) do - # :empty -> - # acc - - # negative -> - # value = - # if map_pair_projection_keeps_full_fst?(negative, bdd) do - # value - # else - # negs - # |> map_split_negative(value, bdd, fn neg_tag, neg_fields -> - # map_pop_domain_bdd(neg_tag, neg_fields, domain_key) - # end) - # |> Enum.reduce(none(), fn {value, _}, acc -> union(value, acc) end) - # end - - # union(value, acc) - # end end) end From 86a1c86449d0d87bdf4c9ff3e9643b5fdb1ce923 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 12 May 2026 17:29:28 +0200 Subject: [PATCH 4/5] Restore intersection precision --- lib/elixir/lib/module/types/descr.ex | 18 ++++++++++-------- .../test/elixir/module/types/descr_test.exs | 4 +--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 9d5042c22f9..0e6bc02dd54 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -3489,17 +3489,19 @@ defmodule Module.Types.Descr do if neg_tag == :closed and map_empty?(map_intersection(bdd, neg_bdd)) do [{value, bdd} | acc] else - diff_bdd = map_difference(bdd, neg_bdd) + intersection_value = intersection(value, neg_value) - cond do - value == neg_value or subtype?(value, neg_value) -> - if map_empty?(diff_bdd), do: acc, else: [{value, diff_bdd} | acc] + if empty?(intersection_value) do + [{value, bdd} | acc] + else + diff_bdd = map_difference(bdd, neg_bdd) - map_empty?(diff_bdd) -> + if map_empty?(diff_bdd) do prepend_pair_unless_empty_diff(value, neg_value, bdd, acc) - - true -> - prepend_pair_unless_empty_diff(value, neg_value, bdd, [{value, diff_bdd} | acc]) + else + acc = [{intersection_value, diff_bdd} | acc] + prepend_pair_unless_empty_diff(value, neg_value, bdd, acc) + end end end end) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 31de6056d59..3789fa40ae2 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -2894,9 +2894,7 @@ defmodule Module.Types.DescrTest do end test "with projected negative maps and no popped value projection" do - # map_put/3 passes nil as the popped value accumulator because it only - # needs the map side. The final negative covers the whole popped map shape - # but not the key value, so this exercises that nil shortcut directly. + # map_put/3 passes nil as the popped value accumulator because it only needs the map side. map = projected_negative_map(100) |> difference(open_map(k: atom(), x: term())) From be9d5b9e4e15903850dd7f1c6adfcab6efb2b034 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 12 May 2026 17:52:05 +0200 Subject: [PATCH 5/5] Account for new hashes + fix test --- lib/elixir/lib/module/types/descr.ex | 4 ++-- lib/elixir/test/elixir/module/types/descr_test.exs | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 0e6bc02dd54..37e32b6b0a7 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -3420,10 +3420,10 @@ defmodule Module.Types.Descr do defp map_split_negative_pairs_key(negs, key) do Enum.reduce_while(negs, [], fn - {:open, empty}, _acc when is_fields_empty(empty) -> + bdd_leaf(:open, empty), _acc when is_fields_empty(empty) -> {:halt, :empty} - {tag, fields}, neg_acc -> + bdd_leaf(tag, fields), neg_acc -> {:cont, [map_pop_key_bdd(tag, fields, key) | neg_acc]} end) end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 3789fa40ae2..4c28fae59a5 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -2888,9 +2888,12 @@ defmodule Module.Types.DescrTest do map = difference(open_map(k: integer(), x: term()), open_map(k: integer(), a: integer())) - assert map_put(map, atom([:k]), binary()) == - {:ok, - difference(open_map(k: binary(), x: term()), open_map(k: binary(), a: integer()))} + {:ok, type} = map_put(map, atom([:k]), binary()) + + assert equal?( + type, + difference(open_map(k: binary(), x: term()), open_map(k: binary(), a: integer())) + ) end test "with projected negative maps and no popped value projection" do