← board

return s[0] gives back the character CODE — a subscript return infers an int type

def f():
    s = "ab"
    return s[0]
print(f())        # CPython: a     pxx: 97

97 is ord("a"). The function's inferred return type is integer, so the character is returned as its code.

With an unannotated PARAMETER the same shape raises instead:

def f(s):
    return s[0]
print(f("ab"))    # CPython: a     pxx: TypeError: expected a number, got str

— the value is a str-tagged variant, the integer return coercion runs, and pyvar_to_int rejects it. Same cause, different symptom depending on whether the receiver is statically a string or a variant.

Boundary — only the DIRECT return

shape pxx
return s[0] (local str) 97
return s[0] (unannotated param) TypeError
c = s[0] then return c a
def f(s) -> str: return s[0] a
return s[0] == "a" correct ✓
return s[0] + "!" correct ✓
print(s[0]) inside the body correct ✓

So assigning to a local first, annotating, or using the value in an expression all work. It is specifically a bare return <subscript> driving the inference.

Cause

An unannotated def's return type is inferred from its first return <expr> (pyparser.inc — "The return type of a def with NO -> ret, inferred from the FIRST return <expr>"). A string subscript yields tyChar, and pxx spells a Char as an ordinal, so the inference lands on an integer type rather than on str. The c = s[0]; return c path works because the local's own typing widens it to a string kind first.

This is the SHAPE-BLINDSPOT family again — tyChar is Python's str of length 1, and every place that treats it as an ordinal instead is a divergence. Related: [[bug-nilpy-char-vs-string-literal-ordering-compares-an-address]].

Not a regression

Reproduced on the stable compiler from 2026-07-27.

Gate

make test-nilpy + self-host byte-identical, plus a .npy of the table above against CPython's own output — including the working rows, so a fix that re-types Char everywhere does not break them.

The SLICE half — same root, worse symptoms

Slicing a variant is typed as a LIST, so a string slice comes back empty:

def f(s):
    return s[0:2]
print(f("abcd"))      # CPython: ab     pxx: []

def common(a, b):
    i = 0
    while i < len(a) and i < len(b) and a[i] == b[i]:
        i = i + 1
    r = a[0:i]
    return r
print(common("prefix_one", "prefix_two"))   # CPython: prefix_   pxx: SIGSEGV
shape pxx
return s[0:2] (unannotated param) []
r = s[0:2] then return r [] — the LOCAL is mistyped too
def f(s) -> str: return s[0:2] correct ✓
print(s[0:2]) inside the body correct ✓ (print takes a variant)
s = "abcd"; s[0:2] at top level correct ✓
the common-prefix function above SIGSEGV

The IR confirms it is not the runtime: pyvar_slice IS called and does handle a string tag correctly. The result is then coerced by the CALLER, because the expression's static type says list — so the string variant gets unboxed as an object.

Unified fix

Both halves are one decision: what static type does v[...] get when v is a variant? Today the subscript answers tyChar (an ordinal) and the slice answers list, and both are guesses that the runtime can already make correctly from the tag.

Typing both as tyVariant and letting the tag decide at run time removes the guess entirely — pyvar_getitem and pyvar_slice both already dispatch on the tag and both already return a Variant. The annotated and print() rows work today precisely because they do not force a scalar type onto the result.

Watch the paths that currently depend on the char typing — ord(s[i]), s[i] == "a" and chr() round-trips all work now and must keep working; they are covered by test/test_nilpy_variant_str_index.npy.

CLOSED — both halves fixed as suggested

Subscript half: PyInferExprType now gives <ident>[...] on a list/dict/unconstrained-variant receiver a tyVariant arm (shared with [[bug-nilpy-return-type-inference-mistypes-several-expression-shapes]], the same root cause seen from the def-return side). A plain string receiver keeps its existing char-yielding arm, which is fine — s[0]'s bare-return path now resolves through the SAME re-chase fix as that sibling ticket rather than landing on tyChar directly, so return s[0] (local str) and return s[0] (unannotated param) both now print the character, not its ordinal or a TypeError.

Slice half: the slice-detection arm assumed any non-string receiver was a list (tk := tyClass; PyInferLastCi := FindUClass('TPyList')), which is exactly the wrong guess for a variant that holds a string at runtime — pyvar_slice already dispatches on the tag correctly, so the fix removes the guess: a receiver whose STATIC type is genuinely tyClass keeps the list answer, but a variant/unknown receiver now gets tyVariant and lets the runtime tag decide, matching what pyvar_slice already returns. This also fixes the common_prefix-shaped SIGSEGV in this ticket (a string built via slicing, returned through a local) — confirmed the same repro no longer crashes and prints the correct prefix.

Tests: test/test_nilpy_bare_return_subscript_slice.npy covers every row in both tables above (including the SIGSEGV repro) against CPython's own output; test/test_nilpy_variant_str_index.npy (ord/chr/comparison round-trips) stays green. Gate: make test-nilpy green, self-host fixedpoint, testmgr --tier quick.

Ticket closed.

Log