← board

math.trunc, math.log(x, base), math.pow, math.copysign and math.atan2 need pymath_* intercepts

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.ceil must 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.

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:

  1. uses math in pylib. It is permitted — pylib already uses typinfo, which is lib/rtl/typinfo.pas — but lib/rtl/math.pas exports Min, Max and Power, and pylib defines its own min/max overloads. That is the recorded "builtin overload COMPETES with a used-unit routine, and argument width steers" hazard, i.e. a silent re-resolution of min/max for every NilPy program. Not worth it for log without measuring that fallout.
  2. A frontend lowering that emits Ln(x) / Ln(base) against the RTL's Ln as 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): Integer is declared before Power(base, exponent: Double): Double and arity cannot tell them apart, so FindProc('Power') handed math.pow the 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

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.atan2 stays 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.