Skip to content

Reworking ComplexInfinity - #87

Draft
PatrickHaecker wants to merge 18 commits into
JuliaMath:masterfrom
PatrickHaecker:complex_direction
Draft

Reworking ComplexInfinity#87
PatrickHaecker wants to merge 18 commits into
JuliaMath:masterfrom
PatrickHaecker:complex_direction

Conversation

@PatrickHaecker

Copy link
Copy Markdown
Contributor

Ok, this is my take on fixing the problems around ComplexInfinity by storing the angle in an UInt64. With this, they should be accurate whenever the users wants them to be accurate. They should be bijective, so no more multiple internal representations which mean the same. No more different quantizations depending on where you are on the complex circle.
So overall they should now behave as similar to Base as possible. However, it's not all perfect. The angle Float64 interface which is important in Base is not a natural fit to this representation and the conversion is not really what I would call elegant.

Details from the 🤖:
Makes a direction and a value the same thing. Two ComplexInfinitys pointing the
same way were not equal and did not hash alike, so one could not be used as a
dictionary key or compared reliably.

julia> ComplexInfinity(0.5) == ComplexInfinity(2.5)   # same direction
false

julia> hash(ComplexInfinity(0.5)) == hash(ComplexInfinity(2.5))
false

Based on #86 — draft until that merges. Review the top three commits.

The representation

The field is now a UInt64 counting turns in units of 2^-64, wrapping where the
circle does. Every UInt64 names a direction and every direction has exactly one
count, so the two are bijective. The group operation becomes a machine add, and the
resolution is uniform instead of thinning out towards a full turn.

Most code needs no constructor at all, since multiplying by takes the direction
from the other operand:

julia> im*cispi(0.5)∞

julia> (1+im)*cispi(0.25)∞

The count itself is the constructor argument; the lossy path is named at the call
site with halfturns, and reinterpret(UInt64, x) reads the count back.

julia> ComplexInfinity(0x4000000000000000)  ComplexInfinity(halfturns = 1//2)  im*true

halfturns and the x*∞ forms go through angle, so they round: exact on the axes
and at a quarter turn, but exp(im*π/8)*∞ lands 256 counts past an eighth turn.
Pass the UInt64 where an off-axis direction has to be exact.

Breaking

before here
ComplexInfinity(0.5) exp(0.5*im*π)∞ MethodError — use halfturns = 0.5
the type ComplexInfinity{Float64} ComplexInfinity, no parameter
5 < ComplexInfinity() true MethodError
div(ComplexInfinity(), 5) exp(false*im*π)∞ MethodError
angle(ComplexInfinity(halfturns = 1.5)) 4.71238898038469 -1.5707963267948966
show exp(0.5*im*π)∞ cispi(0.5)∞
NaN * ComplexInfinity() NotANumber() NotANumber() + NotANumber()*im

angle now reports on Base's branch of (-π, π], which it did not before.
Ordering and the integer operations are gone because the complex plane carries
neither, exactly as Base treats a Complex.

The type parameter is gone

It described how the direction had been spelled, never the value: a zero angle
written as a float pointed along the positive real axis just as ComplexInfinity()
does, yet only the latter could be ordered or divided. The union types that used it
as a proxy for "lies on the real axis" now exclude ComplexInfinity outright, and
the question it was standing in for is asked of the value instead:

julia> isreal(ComplexInfinity()), isreal((1+im)*∞)
(true, false)

julia> RealInfinity(ComplexInfinity())
+∞

julia> RealInfinity((1+im)*∞)
ERROR: InexactError

So an order or an integer operation is still reachable by naming the conversion,
and an infinity off the axis throws rather than quietly dropping its imaginary part.
isreal was a MethodError before.

Two details worth a look

Equality and hash read the count, never the angle. The count has 64 bits and a
Float64 angle has 53, so directions that a float cannot tell apart must still
compare unequal. Reachable by ordinary arithmetic:
ComplexInfinity(halfturns = 0.5) * ComplexInfinity(halfturns = 2.0^-63).

show round-trips. The readable cispi(h)∞ is used only where reading it back
gives the same count; otherwise the count is printed literally, so every printed form
evaluates to the value it came from:

julia> ComplexInfinity(0x5555555555555555)
ComplexInfinity(0x5555555555555555)

Patrick Häcker added 5 commits September 3, 2026 17:13
`Base` promotes every `Real` to `BigFloat`, so `Base._promote` handed the arithmetic
two `BigFloat`s and `__add`, `__mul` and `_infpow` no longer matched: `big(2.0) + ∞`,
`big(2.0) * ∞` and `(+∞)^big(2.0)` were all `MethodError`s.

`BigInt` was unaffected, the `Integer` rule catching it first, and so were `ℵ₀` and
`ComplexInfinity`, neither of which promotes to a float.
`NaN + ∞` gave `∞`, `NaN * ∞` gave `+∞`, `div(NaN, ∞)` gave `0.0` and `(+∞)^NaN` gave
`0.0`, where the same expressions over the floats all give `NaN`. An argument that was
not an infinity was treated as negligible, so a `NaN` marking a failed computation
silently became a plausible infinity.

Each entry point now returns its `NaN` argument unchanged, which keeps the precision as
well: `NaN32 * ∞ === NaN32`. `isnan` is defined for every `Number` and folds to `false` for
the types that carry no `NaN`, so nothing changes for them. A float argument widens the
inferred return type by one union member and still allocates nothing.
For a float parameter it returned the field itself, so `signbit(ComplexInfinity(0.5))`
was `0.5` and any caller branching on it hit a `TypeError`. It now returns whether the
infinity points along the negative real axis, for every parameter type, which is what
the `Bool` and `Integer` methods already did and what `Base` guarantees.

One method replaces the three, as `mod(signbit, 2) == 1` covers them all. The two
places that wanted the whole angle rather than its sign take the field directly.
The three bugs found so far were all the same shape: an infinity behaving differently
from `Inf` in a case nobody had enumerated. The table asks each comparison and each of
`max` and `min` for the same result as the matching float infinity, over a list of
values that includes both zeros, both `NaN` precisions, the subnormal and the largest
finite float.
All four were `MethodError`s for an angle that is not a multiple of π: negation existed
only for an integer factor, and the other three not at all.

Negation and conjugation rotate and reflect the angle, reduced so that both stay
involutions; `abs` is `∞` whichever way the infinity points; `sign` is the unit vector,
`cispi` giving it exactly on the axes. The integer factor keeps its own methods, which
already returned an `Int` and are what `AllRealInfinities` relies on.
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (13f9e1f) to head (421714b).

Additional details and impacted files
@@            Coverage Diff            @@
##            master       #87   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            6         6           
  Lines          266       336   +70     
=========================================
+ Hits           266       336   +70     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Patrick Häcker added 13 commits September 7, 2026 05:03
`ComplexInfinity` stores its direction in half turns, but `toinf` filled the
field with the radians of `angle(x)`. The direction of an infinite complex
summand was therefore off by a factor of π: `angle(toinf(complex(0, Inf)))`
gave 4.93 rather than `π/2`.

`_infadd` compares those angles, so a sum whose parts point the same way threw
although `==` called them equal: both `complex(-Inf, 0.0) + -∞` and
`complex(0.0, Inf) + im*∞` raised an ArgumentError. Only the positive real
axis escaped, angle `0` being the fixed point of the missing scaling.

`_sb` is the conversion the multiplication already uses, so it moves above the
addition and both sections share it.
Both were `MethodError`s, which also made `∞ in 1:5` fail, a range asking `isinteger`
before it compares. `Inf` is not an integer and rounding leaves it alone, so an infinity
does the same and returns itself.

`InfiniteCardinal` is left out of both: it is an `Integer`, for which `Base` already
returns `true` and the value unchanged.
`∞ / 2` and `2 / ∞` were promotion errors, though `inv` was already there to build them
from: division is multiplication by the inverse, which brings the sign and the `NaN`
handling of `*` with it. `\` needs nothing of its own, `Base` defining it as `y / x`.

`∞ / ∞` returns `NotANumber`, as `div(∞, ∞)` and `mod(∞, ∞)` already do, rather than the
`NaN` of the floats. `2 / ∞` inherits the `Int` zero of `inv(∞)` where the floats give
`0.0`, which fixing `inv` will settle in one place. `Rational` and `Complex` need the same
explicit pairs in `ambiguities.jl` as the other operators.
`3 % ∞` was a promotion error, though `mod` and `div` were both already there. `rem` keeps
the sign of the dividend, so unlike `mod` it needs no bound: `-3 % ∞` is `-3`, where
`mod(-3, ∞)` is unbounded and throws. `divrem` follows from the two.

The other direction returns `NotANumber`, as `mod(∞, x)` and `div(∞, ∞)` do. `Rational`
and `BigInt` need the explicit pairs in `ambiguities.jl`, an `InfiniteCardinal` being an
`Integer` that `Base` has its own methods for.
`∞ ≈ Inf` threw, `Base` promoting its arguments before it compares them and an infinity
having no common type with a number. Nothing is near an infinity but an equal one, which
is what the floats do too, so approximate equality is exact equality and the keywords
have nothing to loosen.
Gaps that JuliaMath#68 covers and this branch did not, plus the types they missed.

`round(x, ::RoundingMode)` was a MethodError, and `round(x; digits)` fell
through to `Base` and returned `Inf` rather than the infinity, disagreeing with
the plain `round(x)` next to it. `isinteger` and the four rounding functions
were also defined for `Infinity` and `RealInfinity` alone, so a `ComplexInfinity`
raised a MethodError where `Base` returns `false` and the value itself for the
matching `Complex`, and `ℵ₀` took a rounding mode but not the keywords.

`float(::ComplexInfinity)` was a MethodError, the real infinities having got
theirs from the `AbstractFloat` conversion. JuliaMath#68 proposes `exp(im*angle(x))*Inf`,
which is unsound: `0 * Inf` is a `NaN`, so `float(ComplexInfinity())` gives
`Inf + NaN*im`, and the imaginary and negative real axes come back as diagonals.
`cospi`/`sinpi` are exact at the half-integers, so building the parts from them
keeps the axes exact. Two saturating parts can express only eight rays, so an
angle off them lands on the nearest one, which the test pins.
It satisfies `isnan`, compares false against everything including itself, keeps
`isequal` and `hash` so a container can hold one, sorts last, and converts to
the `NaN` of any float type.

It also becomes a `Real`. Dividing two real numbers has to give a real
number, and `+∞/+∞` is a `NotANumber`, so as a `Number` it made
`isreal(+∞/+∞)` false. The supertype is also what lets `Complex{NotANumber}`
exist, since `Complex` takes a `Real`.

The price is dispatch. A `Real` matches Base's own methods for `Real`, which
are exactly as specific, so every such slot has to be filled here.
`NotANumberRivals` lists the types that need one.
Every operation on it was a MethodError or an ErrorException, so an undefined result could not be carried any further.
Adding infinities of opposite direction and multiplying an infinity by zero threw an ArgumentError, where the floats give NaN. The mod case is left alone, since Base returns -2.0 for mod(-2.0, Inf) rather than NaN.
The float NaN was returned unchanged, keeping its precision, where every other float special value loses it: Inf32 + ∞ is already ∞. This reverses a tested line of the NaN arithmetic PR.
A direction is an angle modulo a full turn, so the field now counts turns in
units of 2^-64 and wraps where the circle does. Every `UInt64` names a
direction and every direction has exactly one count, which the old half-turn
field did not manage: half turns of 0.5 and 2.5 pointed the same way yet
compared unequal and hashed apart. Wrapping also makes the group operation a
machine add, and the resolution is uniform instead of thinning out towards a
full turn.

The count is what the constructor takes. An angle has to be rounded to reach
it, so that step is now named at the call site with the `halfturns` keyword,
and the old `ComplexInfinity(0.5)` is a `MethodError` rather than a silent
reinterpretation. Most code needs neither form, since multiplying by `∞`
takes the direction from the other operand: `im*∞` and `(1+im)*∞`.

The element type carried no information about the value, only about how the
direction had been spelled, so it is gone. It had been standing in for "this
infinity lies on the real axis", and that was never what it meant: a zero
angle written as a float pointed along the positive real axis just as
`ComplexInfinity()` does, yet only the latter could be ordered or divided.
The union types that used the parameter as a proxy now leave a `ComplexInfinity`
out entirely, so the complex plane carries no order and takes no integer
operation, exactly as `Base` treats a `Complex`.

The count has 64 bits and an angle in a `Float64` has 53, so the two are kept
apart wherever the difference shows. Equality and `hash` read the count, never
the angle. `angle` reports on `Base`'s branch of `(-π, π]`. And `show` gives
the readable `cispi(h)∞` only where that reads back, and the count itself
otherwise, so every printed form evaluates to the value it came from.
Dropping the element type removed the only way to ask a `ComplexInfinity`
whether it points along the real axis, and that question was worth keeping:
it just belongs to the value rather than to the type. `isreal` takes it over,
and `RealInfinity` converts a direction that lies on the axis and throws an
`InexactError` otherwise, which is what `Real` does with a `Complex`.

So an order or an integer operation is still reachable, by naming the
conversion, and an infinity off the axis throws instead of quietly behaving
as if the imaginary part were not there.
`Base` returns `NaN` where two reals have no result and `NaN + NaN*im` where
either operand is complex, and this package only did half of that. Anything
computed *from* a `NotANumber` already came back complex against a complex
operand, but every place that *produced* one returned the real value, so
`NaN * ComplexInfinity()` and `NotANumber() * ComplexInfinity()` disagreed.

`_undefined` picks the value from the two operands and now stands wherever an
undefined result is made: a `NaN` argument, a zero times an infinity, two
infinities divided, and two infinities added in different directions. Only
five of those sites can see a complex operand at all; for the rest the choice
is decided at compile time and costs nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant