math.trunc, math.log(x, base), math.pow, math.copysign and math.atan2 need pymath_* intercepts
- Type: bug — Track N (
compiler/pyparser.inc, thePyStdlibCallProctable) - Opened: 2026-08-09
- Filed by: Track B, doing [[feature-rtl-math-surface-gaps]]. Twelve of the
sixteen missing names went into
lib/rtl/math.pasand now match CPython exactly. These four cannot:truncandlogare CONTRACT mismatches — the same shape asmath.floor/math.ceil, which already have intercepts a few lines above where these belong — andpow/copysignare blocked by a NAME-RESOLUTION bug (section 3).
1. math.trunc returns a float
CPython:
math.trunc(-2.5) -> -2 (an int)
A Pascal Trunc(x: Double): Double in the math unit answers -2.0, and the
Python-visible difference is the TYPE, not the value. That is exactly why
math.floor and math.ceil are intercepted as pymath_floor / pymath_ceil
rather than resolved against the RTL:
math.floor/math.ceilmust NOT reach the RTL Math unit's own Floor/Ceil (Double->Double, correct for the Pascal frontend, wrong for Python's int contract)
math.trunc wants pymath_trunc beside them, rounding TOWARD ZERO (which is
floor only for positives — trunc(-2.5) is -2 where floor(-2.5) is -3).
Deliberately NOT added to lib/rtl/math.pas: a Double->Double Trunc there
would resolve ahead of everything and hand every caller the wrong type quietly,
which is worse than the current honest undefined variable (trunc).
2. math.log(x, base) — the two oracles genuinely disagree
Measured 2026-08-09, all three on this box:
log(1000, 10) |
|
|---|---|
CPython math.log(1000, 10) |
2.9999999999999996 |
FPC LogN(10, 1000) |
3.00000000000000000 |
pxx LogN (after [[bug-rtl-log10-is-inexact-for-powers-of-ten]]) |
3.0 |
CPython computes log(x)/log(base) as a plain quotient and does not snap;
FPC's LogN lands exactly on the integer, and pxx's now matches FPC. Note the
argument order differs too (log(x, base) vs LogN(base, x)), so math.log
already needs a shim of some kind.
Neither implementation is wrong — each matches its own language's oracle.
That is what an intercept is for: keep LogN FPC-faithful for the Pascal
frontend, and give NilPy a pymath_log computing CPython's unsnapped quotient.
math.log10 and math.log2 need nothing — CPython and FPC agree there, both
exact, and pxx matches both.
Filed as a bug rather than a Track U question because the NilPy rule settles it:
upward compatibility is about a program CPython accepts and runs, and ordinary
code branches on this value (int(math.log(n, 10)) differs by one) — the same
test devdocs/dev/nilpy-semantics-divergences.md applies to
isinstance(t, list).
3. pow, copysign and atan2 cannot live in the Pascal RTL either
Added there, they hijack libc's in every C program —
[[bug-c-pascal-math-names-hijack-libc-through-pxxcio]], measured: pow(2,10)
answered 1, copysign(3,-1) answered atan2's result, and atan2(0.5,1)
answered atan2(1,1). So even though they are
plain float functions with no contract mismatch, an intercept is the only route
that works until that bug is fixed. Power and the sign-bit logic are already
in lib/rtl/math.pas under non-colliding names for the intercepts to call.
Gate
make test-nilpy green + a .npy test whose expectation is CPython's own
output for math.trunc on negatives and math.log(1000, 10) /
math.log(8, 2).
2026-08-11 — trunc and copysign LANDED; log/pow/atan2 blocked, measured
Two of the five are done and shipped as pymath_trunc / pymath_copysign
intercepts beside pymath_floor/pymath_ceil, exactly where section 1 says
they belong. Both were loud undefined variable compile errors before, so
nothing silently changed behaviour.
math.truncreturnsInt64, so the int contract holds. Rounds toward zero, which is floor only for positives — bothtrunc(-2.5) = -2andfloor(-2.5) = -3are in the test.math.copysignreads the sign fromy's bit pattern rather thany < 0. That matters for one row: CPython'scopysign(3, -0.0)is-3.0, and a comparison answers+3.0because negative zero compares equal to zero.
Test rows added to test_nilpy_math_floor_ceil_int.npy (the arm's own test),
expectations taken from CPython.
Why the other three did NOT land — a builtin unit has no transcendentals
This is the fact the ticket does not record, and it changes the shape of the
remaining work. pylib.pas is a builtin unit. Adding Ln to it fails to
compile:
pascal26:4680: error: undefined variable (Ln)
Ln lives in lib/rtl/math.pas, a library unit. So pymath_log cannot simply
be Ln(x) / Ln(base) the way section 2 assumes — there is no Ln to call.
Two routes remain, neither a one-liner:
uses mathin pylib. It is permitted — pylib already usestypinfo, which islib/rtl/typinfo.pas— butlib/rtl/math.pasexportsMin,MaxandPower, and pylib defines its ownmin/maxoverloads. That is the recorded "builtin overload COMPETES with a used-unit routine, and argument width steers" hazard, i.e. a silent re-resolution ofmin/maxfor every NilPy program. Not worth it forlogwithout measuring that fallout.- A frontend lowering that emits
Ln(x) / Ln(base)against the RTL'sLnas an AST division of two calls, rather than a pylib shim. This keeps the builtin unit out of it entirely and is probably the right answer, but it is not a shim-table entry — the table maps a dotted name to ONE pylib proc.
Note the arity question is already solved either way: PyParseStdlibCall
re-targets by arity through FindProcArity, so a 1-arg and 2-arg pymath_log
overload pair would be reachable (this is the bug-nilpy-stdlib-shim-table- cannot-reach-an-overload fix). Arity is not the blocker; the missing Ln is.
pow and atan2 are blocked on something else again — a 1-ulp libm gap
Measured on this box against CPython:
| pxx | CPython | |
|---|---|---|
ArcTan(0.5) |
0.46364760900080615 | 0.46364760900080609 |
2.0 ** 0.5 |
1.4142135623730949 | 1.4142135623730951 |
3.0 ** 2.5 |
15.588457268119901 | 15.588457268119896 |
So there is no correctly-rounded pow or atan2 reachable to build these on:
NilPy's own float ** is already 1 ulp off, which is
[[bug-nilpy-float-pow-loses-a-ulp-vs-libm]]. Implementing math.pow as
Exp(y*Ln(x)) would be worse still — inexact in general, and wrong outright for
a negative base or x = 0. Shipping either would be a silent wrong value, so
neither was added.
math.pow and math.atan2 should be treated as blocked on
[[bug-nilpy-float-pow-loses-a-ulp-vs-libm]], not on this ticket's own work.
crtl has a correctly-rounded libm (project_crtl_libm_correctly_rounded_dd),
which is the likely source once someone wires it up.
Remaining scope of this ticket
math.log (via route 1 or 2 above), plus math.pow / math.atan2 once a
correctly-rounded libm is reachable. Section 3's name-hijack reasoning still
stands for all three.
Gate for what landed
make compiler/pascal26 (fixedpoint, 1 round) + tools/gate.sh quick GREEN +
make test-nilpy. Worth knowing for the next person: make compiler/pascal26
does NOT compile pylib — the Ln failure above only appears when a .npy
program is compiled, so a pylib edit must be checked by compiling a NilPy
program, not by building the compiler.
Re-measured 2026-08-13 — three of the five names are done; log(x, base) is what is left
math.trunc and math.copysign are in the stdlib call table and correct
(trunc(-2.5) is -2, an int; copysign(3, -1) is -3.0), and math.pow /
math.atan2 answer CPython's values today. So four of this ticket's five names
no longer need anything.
Fixed here: math.log(x), which was not in the ticket's list and was worse
than any of them — undefined variable (log), while log10 and log2 worked.
The RTL spells the natural log Ln, so import math had nothing to resolve
log to. That is a pure NAME difference (both Double->Double, both agree), so
it is one line in the same table, and the ticket's own contract-mismatch
reasoning does not apply to it.
Still refused, deliberately: math.log(x, base). The measured table above
stands — CPython computes an unsnapped log(x)/log(base) (2.9999999999999996
for log(1000, 10)) and the RTL's LogN snaps to 3.0 — so mapping it to LogN
would be a silently wrong value in the last place, which is worse than a
refusal. It now refuses by name:
Nil Python: math.log(x, base) is not supported yet — CPython computes it as
an unsnapped log(x)/log(base), which the RTL's LogN does not reproduce.
Write math.log(x) / math.log(base)
rather than through the generic arity error, which named Ln — an internal
spelling the program never wrote. The suggested workaround is exact: measured,
math.log(1000) / math.log(10) gives CPython's own 2.9999999999999996.
Test test/test_nilpy_math_log.{npy,expected}, wired into test-nilpy. The
ticket stays open for the two-argument form only.
Resolved 2026-08-14 — log(x, base) and pow landed; atan2 handed off
All five names are now settled, four of them working and one deliberately absent with a measured reason.
math.log(x, base) — route 2 of the ticket, a frontend lowering emitting
Ln(x) / Ln(base) as an AST division of two calls, not a pymath_log shim. The
2026-08-11 note was right that a builtin unit has no Ln to call; the lowering
sidesteps that entirely. Measured against CPython: log(1000, 10) is
2.9999999999999996, log(8, 2) is 3.0, log(2.5, 1.5) is 2.259851004564663,
and int(math.log(1000, 10)) is 2 — the row the whole divergence is about, and
the one LogN would have got wrong.
math.pow — mapped to the RTL's Power, and the name mapping alone was
silently wrong, which is the part worth carrying forward:
Power(base, exponent: Integer): Integeris declared beforePower(base, exponent: Double): Doubleand arity cannot tell them apart, soFindProc('Power')handedmath.powthe truncating INTEGER power.math.pow(2.0, 0.5)answered 1,math.pow(3.0, 2.5)answered 1.
FindProcArityDouble picks the all-Double arm. That is the right question for
this table specifically — Python's math module is float-only — and only this
one name asks it, so no other shim changes its answer. This is the type-based
selection PyParseStdlibCall's own note said was "still not reachable"; it now
is, for the one case that needs it.
Correcting the 2026-08-13 note in this ticket: it recorded that "math.pow
/ math.atan2 answer CPython's values today". They did not — both were
undefined variable until this change. What that note presumably measured was
the ** OPERATOR, which is a different path and is still 1 ulp off
([[bug-nilpy-float-pow-loses-a-ulp-vs-libm]]). Worth knowing that the RTL's
Power and NilPy's ** disagree: Power(2.0, 0.5) is CPython's exact
1.41421356237309515 while 2.0 ** 0.5 is 1.4142135623730950. So math.pow
is emphatically not **, and the ulp ticket is about the operator only.
math.atan2 stays absent, deliberately. Measured on this box:
ArcTan2(0.5, 1.0) = 0.46364760900080615 against CPython's 0.46364760900080609
— 1 ulp. atan2(1, 1) and atan2(-1, -1) agree exactly, which is what makes
this the dangerous kind of gap: it is right most of the time. Mapping it would
be a silently wrong value in the last place, so it is left as an honest
undefined variable and belongs to whoever wires up crtl's correctly-rounded
libm ([[project_crtl_libm_correctly_rounded_dd]]), not to this ticket.
Found while sweeping, filed separately: every math DOMAIN error in NilPy —
sqrt(-1), log(-1), log(0), pow(-8, 0.5) — raises
ZeroDivisionError: division by zero where CPython raises ValueError: math domain error, while the same call compiled as Pascal returns a quiet Nan.
One message for all four, so one site.
[[bug-n-math-pow-domain-error-raises-the-wrong-exception]] has the table and
records the two causes that were checked and ruled OUT, so the next person does
not re-derive them.
Gate: test/test_nilpy_math_log.npy extended with the two-argument log
(including the int() row and a via-variables row so it is not a folded-literal
shape) and the pow sweep; expectations regenerated from CPython.
tools/gate.sh quick GREEN, self-host fixedpoint 1 round. No pylib change, so
no re-pin.
Log
- 2026-08-14 — resolved, commit 8241847b2.
Resolved 2026-09-10 — the last of the five names, math.atan2, and the objection that held it was not a measurement
This ticket's own 2026-08-14 resolution said:
math.atan2stays absent, deliberately. Measured on this box:ArcTan2(0.5, 1.0)= 0.46364760900080615 against CPython's 0.46364760900080609 — 1 ulp.
Those two figures are the same double. repr prints
0.4636476090008061; a 20-place fixed readout prints
0.46364760900080609352. Re-measured on the bits: 3FDDAC670561BB4F in
both — and test/lib_math_correctly_rounded.pas has been asserting that exact
constant, by name, since the double-double port landed. The objection was a
readout collision, and it is the mirror of the one CLAUDE.md records under
"could the way I am PRINTING this turn a disagreement into an agreement?" —
here the printing manufactured a DISAGREEMENT out of one value.
The cost of the mistake was not the ulp. It was that the note went into
compiler/pyparser.inc as a hazard block, and a reader who obeys a hazard
block generates nothing that could reveal it was wrong. It held math.atan2
out of the table for a month while it was the largest single missing name in
the lekkerzeilen corpus — 7 of 33 modules.
What WAS wrong, and this ticket never mentioned it
ArcTan2 answered NaN whenever either operand passed
1.3393857490036326e300, and again for every infinite operand, and lost most of
its precision for a subnormal first argument. 913 NaN rows plus 9 wrong rows in
6000 random pairs. All three fixed in lib/rtl/math.pas; the account is in
[[bug-b-arctan-answers-nan-above-1e300-which-is-why-math-atan2-is-still-refused]].
After the fix: 0 of 6000 differ from glibc on atan2, atan(y) and
atan(x) alike.
So the ticket was right that math.atan2 should not have been mapped in August
and wrong about every part of why. A rounding gap that did not exist stood in
for three real defects that did.
What landed here
else if dotted = 'math.atan2' then Result := 'ArcTan2', beside math.atan.
No domain guard and no overflow guard: atan2 has no domain error and cannot
overflow, which is why it is a plain rename and not a pymath_* intercept like
trunc, log(x, base), pow or copysign. The hazard block is corrected in
place and dated rather than deleted, so the next reader sees what it claimed
and why that was wrong.
Guarded by test/test_nilpy_math_atan_and_atan2_bit_for_bit.npy, oracled
against live CPython on the same file rather than a stored .expected, with
the 1.33e300/1.34e300 pair as the positive control and the raw struct bytes
printed beside the reprs so the claim does not rest on the formatter.
All five names in this ticket's title are now done.
- 2026-09-10 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 8241847b2.