← board

NilPy has no complex number type

print((-8) ** (1 / 3))    # CPython (1.0000000000000002+1.7320508075688772j)
                          # pxx     ValueError: ... is complex, which NilPy does not have
print(complex(1, 2))      # CPython (1+2j)
print(1j)

complex is a Python builtin type, so this is a genuine upward-compatibility gap: a program CPython accepts and runs does not run here.

Why it is prio 15 anyway

Complex arithmetic appears in almost no application code outside numerics, and the one place it turns up by ACCIDENT — a negative base raised to a fractional power — now raises a clear, named error rather than hanging or inventing a real number. So the cost of not having it is a loud refusal in a rare spot.

lib/rtl/ucomplex.pas exists on the Pascal side, which is a starting point if this is ever picked up: the type and its arithmetic exist, and the work would be the NilPy surface (the j literal suffix, complex(), .real/.imag, repr, and the arithmetic dunder routing) rather than the maths.

Gate

.npy diffed against CPython: a 1j literal, complex(a, b), .real/.imag, repr of several values, arithmetic between complex and int/float, and (-8) ** (1/3) giving CPython's value.

Implemented — 2026-08-16

complex is a real NilPy type now. The whole gate surface this ticket named is checked in as test/test_nilpy_complex.npy, wired into test-nilpy and diffed live against CPython by the Makefile rule (= "$(python3 ...)"), so every line is an exact agreement rather than a recorded approximation.

Shape: a pylib CLASS, not a new variant tag

A tag would need a heap payload anyway — two doubles do not fit one variant slot — and that means touching all four object-tag sites plus the aarch64 clear/retain pair, which the survey flagged as an existing cross-target asymmetry. As an ordinary tag-7 object every one of those paths already works.

What had to be told about it, one by one

Complex is a pylib class, and pylib classes are deliberately excluded from every dunder-dispatch arm (PyRecIsPylibOwnClass). That exclusion is correct — it is what keeps [1,2] / 3 a loud error instead of arithmetic on a list handle — but it means complex gets nothing for free. Each arm below had to name it, and each one, left undone, produced arithmetic on the object HANDLE:

surface where
1j literal pylexer.inc — expands to pycomplex_make(0.0, v) at LEX time, so it needs no token kind, no parser arm and no folding, and works in every expression position
complex(...) pylib routine resolved BY NAME, the bytes(...) route — no parser intrinsic
.real / .imag PyDeclaredAttrGet (pylib), the one place BOTH attribute getters meet
+ - * /, either order, mixed with int/float parser.inc additive + multiplicative arms
== != parser.inc relational arm
unary - parser.inc tkMinus factor
abs() parser.inc — the arm whose own comment records the identical handle-as-a-number bug for user classes
** PyMakePow, plus the value-dependent float case below
repr / str / print pyvar_repr + pyvar_print_of
type(z).__name__ pyvar_type_name

.real is resolved at RUN time on purpose. The Python names cannot BE the pylib field names: real lexes as a TYPE token in the Pascal lexer, so pylib cannot name its own field that (measured — z.real inside pylib is "no such member"). Mapping the names in the frontend would have to be repeated at every attribute site, and NilPy has five, split by receiver shape — the split this repo keeps re-fixing. Both getters call PyDeclaredAttrGet first, so one arm there serves every shape. Verified on both the bare-ident and chained receivers.

Arithmetic is built as direct calls, not Pascal operator overloads, and that is the interesting half. Overloads are keyed on the LEFT operand and match the right one only as a preference: z + 1 with an Int64 right operand would miss a (TPyComplex, Double) entry, fall back to the first + registered for TPyComplex, and pass the integer where a TPyComplex pointer was expected — silently, since both are one machine word. Coercing both sides and naming the routine has no such failure mode.

** — the case no static type can express

x ** y is a float for a non-negative base and a COMPLEX for a negative one, decided by the VALUES. So the float path now calls pypow_cx, which returns a Variant, and the widening is gated on the EXPONENT: an integral exponent is real for every base, so x ** 2 keeps its Double typing, its direct RTL Power call and its static overflow guard. Only the genuinely value-dependent case widens. (-8) ** (1/3) therefore answers a complex instead of the ValueError this ticket was filed against.

pycomplex_pow uses repeated multiplication for a small integer exponent, as CPython does: 2j ** 2 is exactly (-4+0j) that way and (-4.000000000000001+9.9e-16j) through exp(w ln z).

Accuracy — the one place we do not match CPython

pylib cannot uses math (the RTL's Abs overload set hides pylib's own), so sqrt/sin/cos/atan are hand-rolled beside the existing PyMathLn/PyMathExp. Every line of the checked-in test agrees with CPython exactly; the residual is (-8) ** (1/3):

exact        1                  + 1.7320508075688772935…j
CPython      1.0000000000000002 + 1.7320508075688772j
pxx          1                  + 1.7320508075688767j

Both differ from the exact value in the last ulp, in different components — pxx's real part is exactly right where CPython's is 2 ulp high, and pxx's imaginary part is 5 ulp low. Left alone deliberately under the standing float-accuracy rule, and filed as its own low-prio ticket [[bug-nilpy-complex-pow-is-a-few-ulp-off-cpython]] rather than chased here. It is why that one line is not in the differential test.

Found on the way — and already decided six days earlier

operator + (a: Double; b: TCx) is refused at the declaration while FPC accepts it. Reached from reflected arithmetic, measured, and written up as a new ticket — which was a REDISCOVERY: [[decide-operator-table-keyed-on-one-operand-or-two]] settled the same question on 2026-08-10 ("both keys, obviously. single keyed is an oversight of ours"), but was never re-filed into the owning lane, so it sat in decided/ where ready/next do not look. Now filed as [[feature-a-operator-table-keyed-on-both-operands]], carrying the decision rather than re-proposing one.

The rediscovery's own "fix shape" — accept when EITHER operand is a record/class — was dangerous, and the decide ticket says why: the table is keyed on the LEFT operand, so a scalar-left operator registers under (tkStar, tyInteger, REC_NONE) and plain 3 * 5 would then match it. Relaxing the guard alone turns a wrong refusal into a silent miscompile of arithmetic that has nothing to do with the record. Not implemented, which is the only reason it cost nothing.

Gate

make compiler/pascal26 (self-host fixedpoint, converged round 1), the 23-line CPython oracle, a float-** regression sweep (18 shapes, the two divergent rows confirmed IDENTICAL to the PINNED binary — the pre-existing [[bug-nilpy-float-power-is-a-ulp-off-the-rtl-already-has-the-fix]], not a regression), tools/gate.sh quick GREEN, and a re-pin since compiler/builtin changed.

Log