Iterating a set (or dict) that has been narrowed from a value supertype via isinstance yields garbage for each element: str(x) / repr(x) on an element returns an internal tag (e.g. 4294967297 = 0x1_0000_0001) instead of the real element, even though the whole-container repr() renders correctly. This was found in the wild (a YANG bits leaf decoded into a set of bit-name strings, compared via sorted(list(a)) < sorted(list(b)), which silently returned False both directions because every sorted element was the same garbage tag).
Minimal reproducer (compile and run with a from-source build at v0.28.1-14-g0a9302956):
def show(v: value) -> None:
if isinstance(v, set):
parts = []
for x in v:
parts.append(str(x))
print("per-element str() while iterating: " + ", ".join(parts))
print("whole-set repr(): " + repr(v))
actor main(env):
s = set(["a", "b"])
print("direct (set[str]) iteration: " + ", ".join([str(x) for x in s]))
show(s)
env.exit(0)
Observed output:
direct (set[str]) iteration: b, a
per-element str() while iterating: 4294967297, 4294967297
whole-set repr(): {'b', 'a'}
The second line should be a, b in some order.
Testing list/set/dict side by side shows the bug is specific to the Hashable-bounded containers:
set per-elem: 4294967297, 4294967297 whole: {'b', 'a'} (BROKEN)
list per-elem: a, b, c whole: ['a', 'b', 'c'] (ok)
dict per-key: 4294967297, 4294967297 whole: {'x':1, 'y':2} (BROKEN)
And the bug only manifests when the actual element type differs from the type the solver picks: a value-narrowed set[int] / list[int] iterate fine, because the elements really are ints. Containers store boxed elements uniformly, so this is not a storage issue.
Root cause. inferTest for IsInstance (compiler/lib/src/Acton/Types.hs, around line 2351) narrows isinstance(v, set) to set[T] using a fresh univar for each of the class's type parameters:
inferTest env (IsInstance l e@(Var _ (NoQ n)) c)
= case findQName c env of
NClass q _ _ _ -> do
(cs,t,e') <- infer env e
ts <- newUnivars env [ tvkind v | v <- qbound q ]
let tc = tCon (TC c ts)
...
The type argument cannot be recovered from the operand's static type (value), so T is an unconstrained univar. For set/dict the parameter is Hashable-bounded, so iterating the narrowed container produces a Hashable[T] constraint, and the solver defaults it to the oldest Hashable instance, which is int — an unboxable type. The boxing pass then unboxes each element as an int. The generated C for the show function makes it concrete:
B_Hashable W_show_66 = (B_Hashable)B_HashableD_intG_witness; // element witness defaulted to int
...
int64_t x = ((B_int)__next__(N_iter))->val; // UNBOX a (boxed B_str) element as int
...append(parts, B_strG_new((B_value)toB_int(x))); // re-box the garbage word
((B_int)elem)->val reads a B_str object through the B_int layout, yielding the 0x1_0000_0001 tag.
list is unaffected because its element parameter has no Hashable bound, so T stays free and the Cast T value arising from str(x) resolves it to value — the element stays boxed and str/repr dispatch dynamically (the loop variable is generated as B_value x, and str()/repr() take a value and dispatch on the real ->$class). The whole-container repr() works for the same reason: it uses each element's own stored witness rather than the static one.
So the only sound static element type for a value-narrowed generic container is value (dynamic dispatch), exactly like the working list path. The obstacle is that set/dict require A: Hashable, and value is not Hashable (the base value vtable has no __hash__/__eq__), so set[value] is rejected today:
def f(s: set[value]) -> int: return len(s)
# error: __builtin__.value must implement __builtin__.Hashable
set[Hashable] does work, but only as a function parameter, where the existential is opened at the call boundary and the caller supplies the element's Hashable witness as an extra argument. That has no analogue for an internal isinstance narrowing — a bare value carries no element witness — and the type checker rejects a protocol as a non-parameter (local) type ("Expected a type, actual kind is protocol").
Possible directions (looking for input on which is the right design):
-
Make value Hashable (and Eq) via identity hashing, reusing the runtime's existing B_HashableD_WORD machinery, and narrow Hashable-bounded isinstance parameters to value. This is the sound, general fix: set[value] / dict[value, _] iterate with dynamic dispatch. Cost: it is a base (Acton + C) and compiler change; it makes == on two statically-value operands resolve to identity equality and lets sets/dicts of arbitrary values typecheck; and it adds value to the Hashable/Eq instance set (registering its witnesses as newest keeps the oldest-first ambiguous defaulting unchanged, so other code still defaults to int). The semantic question — should value be hashable/comparable by identity, Python-style — is really the crux.
-
Decouple set/dict iteration from Hashable. The runtime set/dict iterators ignore the Hashable witness, so at the type level iteration arguably should not require it. Give the containers an Iterable path independent of the Hashable bound, so a value-narrowed container's element stays value like list does, with no need to make value Hashable. Risk: overlapping container extensions and changing how for over a set/dict resolves its witness everywhere.
-
Compiler-only: narrow Hashable-bounded isinstance parameters to a boxed concrete Hashable type (e.g. str) instead of int. Small and makes str/repr correct, but it is a type-lie — elements typed str would let str-specific operations compile and corrupt non-str elements. Least correct; noted only for completeness.
The downstream project already worked around this by deriving an ordering key from the whole-set repr() (which is correct), so there is no urgency, but the underlying behavior is a real correctness trap for any value-narrowed generic container.
Iterating a
set(ordict) that has been narrowed from avaluesupertype viaisinstanceyields garbage for each element:str(x)/repr(x)on an element returns an internal tag (e.g.4294967297=0x1_0000_0001) instead of the real element, even though the whole-containerrepr()renders correctly. This was found in the wild (a YANGbitsleaf decoded into asetof bit-name strings, compared viasorted(list(a)) < sorted(list(b)), which silently returned False both directions because every sorted element was the same garbage tag).Minimal reproducer (compile and run with a from-source build at v0.28.1-14-g0a9302956):
Observed output:
The second line should be
a, bin some order.Testing list/set/dict side by side shows the bug is specific to the Hashable-bounded containers:
And the bug only manifests when the actual element type differs from the type the solver picks: a value-narrowed
set[int]/list[int]iterate fine, because the elements really are ints. Containers store boxed elements uniformly, so this is not a storage issue.Root cause.
inferTestforIsInstance(compiler/lib/src/Acton/Types.hs, around line 2351) narrowsisinstance(v, set)toset[T]using a fresh univar for each of the class's type parameters:The type argument cannot be recovered from the operand's static type (
value), soTis an unconstrained univar. Forset/dictthe parameter isHashable-bounded, so iterating the narrowed container produces aHashable[T]constraint, and the solver defaults it to the oldest Hashable instance, which isint— an unboxable type. The boxing pass then unboxes each element as an int. The generated C for theshowfunction makes it concrete:((B_int)elem)->valreads aB_strobject through theB_intlayout, yielding the0x1_0000_0001tag.listis unaffected because its element parameter has no Hashable bound, soTstays free and theCast T valuearising fromstr(x)resolves it tovalue— the element stays boxed andstr/reprdispatch dynamically (the loop variable is generated asB_value x, andstr()/repr()take avalueand dispatch on the real->$class). The whole-containerrepr()works for the same reason: it uses each element's own stored witness rather than the static one.So the only sound static element type for a value-narrowed generic container is
value(dynamic dispatch), exactly like the workinglistpath. The obstacle is thatset/dictrequireA: Hashable, andvalueis not Hashable (the basevaluevtable has no__hash__/__eq__), soset[value]is rejected today:set[Hashable]does work, but only as a function parameter, where the existential is opened at the call boundary and the caller supplies the element's Hashable witness as an extra argument. That has no analogue for an internalisinstancenarrowing — a barevaluecarries no element witness — and the type checker rejects a protocol as a non-parameter (local) type ("Expected a type, actual kind is protocol").Possible directions (looking for input on which is the right design):
Make
valueHashable (and Eq) via identity hashing, reusing the runtime's existingB_HashableD_WORDmachinery, and narrow Hashable-boundedisinstanceparameters tovalue. This is the sound, general fix:set[value]/dict[value, _]iterate with dynamic dispatch. Cost: it is a base (Acton + C) and compiler change; it makes==on two statically-valueoperands resolve to identity equality and lets sets/dicts of arbitrary values typecheck; and it addsvalueto the Hashable/Eq instance set (registering its witnesses as newest keeps the oldest-first ambiguous defaulting unchanged, so other code still defaults toint). The semantic question — shouldvaluebe hashable/comparable by identity, Python-style — is really the crux.Decouple set/dict iteration from
Hashable. The runtime set/dict iterators ignore the Hashable witness, so at the type level iteration arguably should not require it. Give the containers anIterablepath independent of theHashablebound, so a value-narrowed container's element staysvaluelikelistdoes, with no need to makevalueHashable. Risk: overlapping container extensions and changing howforover a set/dict resolves its witness everywhere.Compiler-only: narrow Hashable-bounded
isinstanceparameters to a boxed concrete Hashable type (e.g.str) instead ofint. Small and makesstr/reprcorrect, but it is a type-lie — elements typedstrwould letstr-specific operations compile and corrupt non-strelements. Least correct; noted only for completeness.The downstream project already worked around this by deriving an ordering key from the whole-set
repr()(which is correct), so there is no urgency, but the underlying behavior is a real correctness trap for any value-narrowed generic container.