← board

How it was reached

Uncovered by fixing bug-n-a-dynamically-dispatched-call-fills-its-defaults-from-another-class-signature.

IT WAS NOT REACHABLE BEFORE, AND THAT IS THE WHOLE REASON IT IS ONLY BEING FILED NOW. On the pre-fix compiler every row of the table below fails IDENTICALLY and EARLIER, with m_dflt() missing positional argument(s) -- the call never completed, so nothing ever marshalled a result. Measured both ways in one session, same reduction, same tree, compiler stashed and rebuilt to get the control (pre-fix 55cdf94233a9, post-fix 54fa192dc21f).

That is the honest shape of it: the defaults fix is a strict improvement that made these calls RUN, and running them is what exposed the layer underneath.

Repro

A package, because the receiver must have no static type at the call site -- which is what puts the call on the run-time path at all. early.py is parsed first and declares nothing named m_dflt, so the scan finds no candidate, warns, and defers to pydyn_meth2.

pkg/early.py:

class Sampler:
    def __init__(self, g=None):
        self.g = g

    def t(self):
        return self.g.m_dflt(1, 2)

pkg/late.py -- vary ONLY the return:

class Grid:
    def m_dflt(self, x, z, outside=77.5):
        return "CONST"          # ...and the variants in the table

pkg/__init__.py:

from .early import Sampler
from .late import Grid


def run():
    print(Sampler(Grid()).t())

The table

Call site held fixed at self.g.m_dflt(1, 2); only the callee's return varies.

callee returns CPython pxx @ 54fa192dc21f pxx pre-fix
"CONST" CONST TypeError: expected a number, got str missing-arg
"d %r %r %r" % (x, z, outside) d 1 2 77.5 TypeError: expected a number, got str missing-arg
"d " + str(x) + " " + str(outside) d 1 77.5 TypeError: expected a number, got str missing-arg
outside (a float, 77.5) 77.5 77 missing-arg
42 42 42 missing-arg

Readings:

  1. The int row is the trap. It is correct, it is the row anyone writes first, and it is correct for the wrong reason -- the value survives because the coercion is to its own type. An expected value that collides with the failure value is a row that cannot fail, and 42 is exactly that.
  2. The float row is the one that says what is happening. 77.5 -> 77 is not a failed conversion, it is a SUCCESSFUL one to the wrong type. The string row is the same coercion meeting a value it cannot take, which is why it gets a diagnostic and the float row does not.
  3. It is not arity and it is not the callee's body. Both were varied and neither moves it: 2 and 3 arguments behave the same, and a constant string fails exactly as a formatted one does.
  4. A MEASUREMENT THAT REACHED THE SUBJECT BY THE WRONG ROUTE SAID THIS WAS FINE. A six-row matrix driven through a tuple of BOUND METHODS (for tag, fn in (("a", s.a), ...)) passed all six. A bound-method value is a different dispatch path, so the probe never reached the one under test -- isolation guards the run, not the route. The rows above are called directly.

Where to look

PyMakeDynMethCall tags its AN_CALL tyVariant and sets LastExprTk to match, so the node is right when it is built. Suspect the RETURN typing of the enclosing def (the value is returned straight out of t), or the assignment/print context narrowing a variant-typed call result. The float row is the cheap discriminator -- anything that prints 77 has already chosen an integer.

Gate

make test-nilpy + self-host byte-identical, plus a .npy carrying the five rows above with expectations from CPython. The float row and a string row are both required; an int-only fixture passes on the unfixed compiler.

Log