← board

-v, ~v and abs(v) on a user class RAISE when the operand is a variant

The binary operator dunders have a runtime fallback for a VARIANT operand (pyadd_v -> PyVarUserArith -> PyUserArithCall1, pylib.pas). The unary ones do not, so the identical class works statically and fails through a parameter.

Measured 2026-09-14 at 17e5731a7, CPython as oracle, class declaring all four:

expression operand is a local with a static class type operand is an unannotated PARAMETER
-a 2.0 (correct) TypeError: expected a number, got object
~a (not measured) prints NOTHING and dies -- no diagnostic at all
abs(a) (not measured) TypeError: expected a number, got object
+a -- answers, but the right answer here COLLIDES with the no-op default, so this row proves nothing and is not a claim

CPython answers all four.

WHY THE VARIANT ARM IS THE ONE THAT MATTERS

A variant operand is not an edge case: an unannotated parameter, a for-loop variable, a container element and an attribute are all variants, and ordinary Python annotates none of them. /home/neo/lekkerzeilen/lekkerzeilen/math3d.py has ZERO parameter annotations across the whole module and declares Vec3.__neg__ at line 59 -- so every -v in that package that crosses a function boundary raises.

This is the same route trap as bug-n-a-user-operator-on-a-variant-operand-leaks-its-result, found in the same hour: the compile-time dispatch in the parser is correct and the runtime arm behind it is not. A fixture written with annotated locals passes both.

REPRO

class V:
    def __init__(self, x=0.0):
        self.x = float(x)
    def __neg__(self):
        return V(-self.x)

def f(a):          # `a` is a variant -- this is the whole bug
    return (-a).x

print(f(V(1.0)))   # CPython: -1.0     pxx: TypeError: expected a number, got object

Drop the function and evaluate -A at module level against a static A and it is correct, which is why this does not show up in a small test.

WHERE

PyUserArithCall1 / PyVarUserArith (pylib.pas) are the binary shape -- (self, other), pk[1] <> 22 gates on a Variant second parameter. The unary dunders take (self) only, so none of that machinery fits them and there is no one-argument twin. The numeric unary path raises "expected a number, got object" before any dunder lookup happens.

~ printing nothing is worse than the other two and should be diagnosed separately -- a silent death is not the same failure as a raise.

REACHABILITY IN THE ONE REAL PROGRAM WE HAVE: ONE SITE, AND IT WORKS

Censused by lekkerzeilen-c8, 2026-09-14, over the whole package: 353 non-literal unary / abs() sites. All but one are numeric (-wind.x, abs(self.rudder)). Zero ~ on anything. Zero abs(<object>) -- math3d declares no __abs__, so those would fail under CPython too.

The single object-operand site is vessel.py:733, Mat4.translation(-offset), where offset is a LOCAL assigned from Vec3(*pivot). It runs for every non-hull fitting of every drawn vessel every frame and does NOT raise -- the local kept a static class type (star-unpacking does not defeat the inference), so it takes the compile-time arm.

That is reachability, not correctness: the site never reaches the runtime arm, which is why it works, and it says nothing about whether the runtime arm is right. Prio dropped 85 -> 55 on that census.

NOT FIXED HERE AND WHY

Found while fixing two object-lifetime leaks on the same evening; this is a DIFFERENT mechanism (dispatch, not refcounting) and wiring a one-argument dunder caller is its own change. Banked rather than microfixed.