← board

Measured 2026-09-10, compiler 5a2cef142106

The boundary

x ArcTan(x)
1e295 … 1e300 bit-identical to CPython
1e301 and above FFF8000000000000 (NaN); CPython 3FF921FB54442D18 (pi/2)
-1e301 and below 7FF8000000000000; CPython BFF921FB54442D18

The sweep that says it is the ONLY divergence

1005 atan2 argument pairs — 600 uniform over ±1e4, the four sign combinations on and near both axes, every decade from 1e-320 to 1e308 as a ratio, and 120 subnormal pairs — compared as raw IEEE bits against CPython:

identical: 996 of 1005
differ:      9   all of them this NaN

The differing rows are atan2(±1, 1e-320 … 1e-304) and atan2(±1, 1e304) / atan2(1e304, 1) — i.e. exactly where |y|/|x| lands above the ArcTan boundary. A separate 316-point ArcTan sweep isolates it to ArcTan itself: 312 identical, 4 differ, all |x| >= 1e301.

Why the recorded objection is retired

compiler/pyparser.inc's math table carries:

math.atan2 is still absent, and stays absent: ArcTan2 is 1 ulp off CPython for atan2(0.5, 1) (0.46364760900080615 vs ...09), so mapping it would be a silently wrong value in the last place. Blocked on a correctly-rounded libm.

atan2(0.5, 1) is bit-identical: 3FDDAC670561BB4F in both. The two figures in that note are the same double printed two ways — repr gives 0.4636476090008061, a 20-place fixed readout gives 0.46364760900080609352. This is CLAUDE.md's readout-collision rule running in the other direction: the readout manufactured a DISAGREEMENT rather than an agreement, and the note has kept math.atan2 out of the table ever since.

A hazard block decays like a lock, not like a fact — nobody who obeys it generates anything that could reveal it was wrong. This one was obeyed.

The mechanism

DdAtan inverts for t.Hi > 1.0 via DdDiv(one, t), then runs a half-angle reduction. Somewhere on that path a double-double intermediate overflows for a very large tArcTan2 reaches it through q := DdDivD(Dd2Sum(Abs(y), 0.0), Abs(x)). The answer for any |t| past about 2/eps is pi/2 to the last bit anyway, so the fix is a magnitude guard ahead of the reduction, not a better series.

What a fix must carry

The bit-level differential, not a printed comparison — a :0:20 readout is what hid this for as long as it has been hidden. test/ has no ArcTan differential today; the sweep above is the shape, with the boundary rows (1e300 correct, 1e301 correct) as the positive control, since a guard placed at the wrong threshold passes every ordinary row.

What it unblocks

math.atan2 is the largest single wall in the lekkerzeilen corpus: 6 modules (hud, rig, sim, traffic, vessel, world) as of the 2026-09-10 census, out of 33.

Resolved 2026-09-10 — the cause was one line further down than this ticket said, and there were three defects, not one

My own mechanism section above was wrong and it is left standing rather than edited. It said "somewhere on that path a double-double intermediate overflows ... the fix is a magnitude guard ahead of the reduction, not a better series". The first half is right in outline and useless in practice; the second half names a remedy that would have papered over the real defect and left every other caller of the same kernel broken.

1. The Dekker split overflows long before the product does

Dd2Prod — the two-product every double-double multiply, divide and square root in lib/rtl/math.pas is built on — split its operands by multiplying by 2^27+1. That product is +Inf for |a| > DBL_MAX/(2^27+1) = 1.3393857490036326e300, so sa - (sa - a) was Inf - Inf = NaN for an argument whose actual product is finite and small.

The boundary this ticket recorded as "1e300 correct, 1e301 NaN" is exactly that constant. ArcTan reaches it because DdAtan inverts a large t through DdDiv, whose first step is this multiply.

Fixed by scaling by 2^-28 before the split and scaling the product back, not the halves. Scaling the halves back is the obvious spelling and it still answered NaN for DBL_MAX itself — ah is a rounded UP to 26 bits, so ah * 2^28 is 2^1024 while a is finite. SqrtSoft, a few hundred lines up in the same file, documents that exact trap on gh*gh and its comment already prescribed the answer: "one exact power-of-two scaling ... removes the special case instead of adding a second one." Of two paths for one concept, the one nobody extended is the one that stayed broken.

2. ArcTan2 formed a quotient that overflows

Independent of the split: |y|/|x| past DBL_MAX gave DdDivD an Inf to subtract from an Inf, and the NaN it returned carried no sign to recover from. ArcTan2(1e301, -1e-301) answered NaN where glibc answers pi/2. Guarded ahead of the division — pi/2 is correctly rounded for the whole region, since a ratio beyond DBL_MAX puts the angle within 5.6e-309 of pi/2 and one ulp of pi/2 is 2.2e-16.

3. ArcTan2 had no answer for an infinite operand at all

Not in this ticket and not in any other. C99 F.10.1.4 gives all twelve combinations a value and only the two signs decide; every one of them was NaN. Ten of the nineteen infinite pairs in the sweep were right by accident, which is why a pass/fail count alone would have called this fixed.

4. And the mirror at the other end: a SUBNORMAL dividend

Found by the same sweep once the NaNs cleared. DdDivD and DdDiv refine their quotient by multiplying it back, so the product they form is a.Hi again — and when a.Hi is subnormal that product underflows, the Dekker residual is noise rather than the exact error, and dividing the noise by b amplifies it by 1/b. Nine of 6000 pairs were out by up to ~450000 ulp, and on every one the plain double quotient was already the correctly rounded answer: the double-double refinement is what made them worse. More precision, applied where the representation cannot hold it, is less precision.

Fixed by the same one-exact-scaling shape at the other end of the range. It is a LOOP and not one 2^64 step because being normal is not the bar — the residual sits another 2^53 below the product, so a dividend just above DBL_MIN is still 1 ulp out after a single lift. Measured: one step took the sweep from 9 disagreements to 1, the loop to 0.

The measurement

6000 random (y, x) pairs, exponents uniform over [-320, 308], both signs, subnormals and infinities included, compared against glibc through CPython on the raw IEEE bits:

before after
rows carrying a NaN 913 0
ArcTan2 differs from glibc 913 NaN + 9 wrong 0
ArcTan(y) differs from glibc 0
ArcTan(x) differs from glibc 0
rows the fix changed from one FINITE value to a different one 0

That last row is the one that says the change is additive: no answer that was already right moved.

The harness lied first, and that is worth more than the fix

The first run of this sweep reported 2569 of 6000 ArcTan values wrong against a file header that says ArcTan is exact. It was the instrument: the Pascal side read its arguments as decimal strings through Val, which is not a correctly-rounded parser — 1574 of the same 6000 strings reach a different double than CPython's float() does. Filed as [[bug-b-val-of-a-float-is-not-correctly-rounded-while-strtofloat-of-the-same-string-is]]; StrToFloat on the identical strings is 0 of 6000 wrong, so it is two mechanisms for one concept again.

Feeding the sweep as raw hex bits, with a round-trip precondition asserted and BRANCHED on (Halt(3) if Bits(FromBits(h)) <> h), took it to 0 of 6000. A comparison whose inputs were never proven to be the inputs cannot fail honestly.

What landed

Log