Numeric types¶
Annotations for concrete numeric types are exact: bool means bool,
int means int, float means float, and complex means
complex. Code that intentionally wants a broader numeric promise uses a
capability trait or an explicit union.
No numeric tower¶
Lucid does not have a numeric tower. Numeric types do not inherit from abstract
numeric base classes such as Integral, Real, or Complex.
Python's numeric tower tries to describe numbers as a single mathematical
hierarchy, but practical APIs usually need narrower promises. An API that needs
an exact index wants __index__, not every value that can be converted with
int(x). An API that can add and multiply values may not support ordering,
bitwise operations, hashing, or lossless conversion. float and complex
are especially awkward in a tower: complex supports arithmetic but not
ordering, while float accepts many integer-like values at runtime without
making every integer an appropriate value for a floating-point API.
The tower also makes annotations less literal. If Real is used because
float feels too narrow, the API may accidentally accept integers, booleans,
fractions, decimals, or third-party numeric objects even when the implementation
only works for a smaller operation set. Lucid replaces the tower with exact
concrete annotations and small, focused capability traits.
Exact bool¶
bool is a distinct logical type, not a numeric subtype.
Python lets boolean values leak into numeric code because bool is a subtype
of int. Lucid rejects those cases so flags cannot silently become counts,
indexes, or bit masks. The numeric and bitwise operators +, -, *,
/, %, |, &, and ^ are invalid for boolean operands.
retries: int = is_retry # error: bool is not int
total = completed + failed # error if both names are bool flags
page = pages[is_admin] # error: bool is not an index
mask = can_read | can_write # error: use boolean operators for flags
retry_count = int(is_retry)
total = int(completed) + int(failed)
mask = int(can_read) | (int(can_write) << 1)
__bool__, __len__, and built-in
emptiness rules. Lucid conditionals require a boolean value or an explicit
boolean protocol. Length does not imply truth, and values are not automatically
truthy just because they are nonzero, non-empty, or non-null.
if ready:
run()
if 1: # error
if "hello": # error unless str explicitly implements truth behavior
if items: # error unless the type explicitly implements truth behavior
__bool__.
A sized type can opt in explicitly:
trait Sized:
def __len__(self: ~Self) -> int
trait SizedTruthy(Sized, Truthy):
def __bool__(self: ~Self) -> bool:
return self.__len__() != 0
Exact int¶
int means integer, not int | bool and not every value that can be
converted with int(x). Integer operations are integer operations: indexing,
bitwise operations, shifts, and integer arithmetic are available for integer
values and for types that explicitly provide the relevant operation.
Integer literals may use decimal, hexadecimal (0x), octal (0o), or binary
(0b) notation. The spelling does not change the exact int type; values too
large for the machine representation remain arbitrary-precision integers.
index: int = 3
items[index]
flags = read | write
shifted = flags << 2
index = true # error: bool is not int
index = "3" # error: explicit conversion required
index = int("3")
Exact float and float-like input¶
Code that wants "anything I can convert to a float" asks for
SupportsFloat. int can satisfy SupportsFloat because it provides
explicit float conversion; that does not make int a subtype or view of
float.
def mean(xs: Iterable[SupportsFloat]) -> float:
total = 0.0
count = 0
for x in xs:
total += float(x)
count += 1
return total / count
mean([1, 2.5, Decimal("3.5")])
float annotation means exactly float:
~float is the read-only view of float. It does not mean
int | float and does not turn integer values into floating-point values.
Scalar values are already immutable in practice, so ~float is mainly useful
for uniform view syntax in generic APIs; it is not the way to spell
float-like input.
Exact complex¶
complex means complex. Complex values support arithmetic but not ordering.
Code that accepts complex values should not accidentally promise ordering just
because other numeric types are orderable.
Infinity and NaN¶
Python spells these two special float values inconsistently:
math.inf and math.nan live in a separate module import, while
float("inf") and float("nan") parse a string to reach the same
values a different way.
Lucid puts both directly on float, as ordinary class member
variables — no import, no string parsing:
float.nan is still IEEE 754 NaN and keeps the one property every
IEEE 754 float shares: float.nan != float.nan. That is a fact about
the value, inherited from the standard float already follows, not a
Lucid-specific exception to Eq's usual reflexivity.
Infinity and NaN for int¶
int is arbitrary-precision — it grows to whatever size a value
needs, with no fixed width to run out of. That already means adding
int.inf/int.nan costs nothing a fixed-width integer would have
to pay: there is no bit pattern to reserve and no legitimate value to
give up, since the representation is free to carry an extra tag
alongside its digits, the same way it already carries a sign. A
reserved tag can never collide with a real integer — exactly the
property that lets float.nan/float.inf work safely, and the
one a fixed-width integer would not have.
int operations with no
defined answer today — produce them instead of raising:
True division, /, already promotes both operands to float
before dividing (Exact float and float-like input), so 5 / 0
was already float.inf; this only fills in the two operators that
stay int-typed and, until now, had no answer at all. int.inf
and int.nan propagate through further arithmetic and compare the
same way their float counterparts do, int.nan != int.nan
included — matching a contract callers already learned once, rather
than a second, subtly different one just for int.
Because both are ordinary int values, not a separate wrapper or
sentinel type, they pass anywhere an int already does — a
limit: int = int.inf default meaning "unlimited" reads the same
way float("inf") already gets used as a sentinel today, with no
special-casing needed at the call site.
range's own stop parameter is a plain int, not
int | none, for exactly this reason: an unbounded range is just
range(start, int.inf, step), and every existing termination rule
already produces the right answer without a separate case for it.
Counting up needs nothing new — current < int.inf holds forever.
Counting down toward an unbounded upper stop terminates immediately,
correctly, from that same comparison:
none cannot do either of those — it takes no part in a comparison
at all, so both directions would need their own branch to special-case
what "unbounded" means for that particular step's sign.
This replaces a raised ZeroDivisionError with an ordinary,
checkable value — the same trade Results makes everywhere
else a recoverable outcome is involved, later in this reading order,
extended to the one place integer arithmetic still had an unchecked
exception instead of one.
NaN for complex¶
complex.nan is a single, canonical invalid value the same way
float.nan is — every NaN already compares unequal to itself and to
every other NaN, so one classvar covers every "not a valid complex
number" case there is. Infinity does not have that same single,
canonical answer: a complex value can diverge along any direction in
the plane, not just toward one distinguished point, so there is no
one complex.inf to name. A specific infinite complex value is built
the same way any other one is, from its real and imaginary parts —
complex(float.inf) for the real axis, complex(0, float.inf) for
the imaginary one — rather than reached for as a classvar.
pow dispatches per type¶
Python's own type stubs cannot give ** a real return type. int ** int
returns int for a non-negative exponent but silently becomes float
for a negative one (2 ** -1 == 0.5), a choice that depends on the
value of the exponent, not its type — so typeshed types it Any.
float ** float is worse: a negative base with a non-integer exponent
doesn't raise or return nan, it silently becomes a complex number
at runtime, another value-dependent type change typeshed again papers
over with Any. Either way, the one line that actually computes the
power is the one line a type checker has stopped checking, no matter
how precisely the operands were annotated.
pow's zero-base, negative-exponent case is the same undefined-answer
problem floor division and modulo already had — pow(0, -1) is
1 / 0 written differently, not a new kind of question. pow is
multiple-dispatch, one case per base type, and each returns the
inf/nan its own type already has instead of raising or changing
type, so the return type is exactly what the signature already
says:
def dispatch pow(base: int, exponent: int) -> int:
if base == 0 and exponent < 0:
return int.inf
...
def dispatch pow(base: float, exponent: float) -> float:
if base == 0.0 and exponent < 0.0:
return float.inf
...
def dispatch pow(base: complex, exponent: complex) -> complex:
if base == 0 and exponent.real < 0:
return complex(float.inf)
...
0 ** 0 stays 1, the ordinary convention, in every case — only a
negative exponent on a zero base has no defined answer to give.
Each dispatch case's result stays the type it was called with — pow
never widens int to float or float to complex to make room for
an answer the input type can't represent, the same trade the zero-base
case already makes. A negative exponent on an int base other than
0, 1, or -1 has no exact int answer either — 2 ** -1 is
0.5, not an integer — so it returns int.nan rather than silently
becoming a float:
float base with a non-integer exponent has the same
problem one level up: (-8.0) ** (1.0 / 3.0) has a real cube root,
-2.0, but also two complex ones, and nothing about a float result
says which the caller wanted, so it's float.nan. A caller who wants
the complex answer casts to complex first, the same way any other
narrowing is asked for explicitly rather than inferred from context:
A third parameter changes the job, not just the answer: Python's own
three-argument pow(base, exponent, modulus) computes (base **
exponent) % modulus by modular exponentiation, without ever
materializing the full power — a different algorithm, not
pow(base, exponent) % modulus with a shortcut taken, and one that
only makes sense for int. Rather than an optional third parameter
defaulting to none, this gets its own dispatch case: Dispatch
beyond operators already
established that a call's argument count is part of what dispatch
resolves on, so a two-argument and a three-argument pow are simply
two distinct, unambiguous cases, the same way pop(self),
pop(self, i), and pop(self, i, j) already are:
Capability traits¶
The numeric capability traits are builtins and are always available
without import. They are ordinary nominal traits, not structural ones:
a type satisfies SupportsInt because its class header explicitly declares
it, the same way any user-defined class declares any other trait —
int itself explicitly inherits from SupportsInt (and the other
capability traits it satisfies), rather than qualifying merely by
happening to define a matching method.
trait SupportsInt:
def __int__(self: ~Self) -> int
trait SupportsFloat:
def __float__(self: ~Self) -> float
trait SupportsComplex:
def __complex__(self: ~Self) -> complex
trait SupportsIndex:
# Exact indexability, not just explicit int(x) conversion.
def __index__(self: ~Self) -> int
trait SupportsAbs[K]:
def __abs__(self: ~Self) -> K
trait SupportsRound[K]:
def __round__(self: ~Self, ndigits: int | none = none) -> K
class int(SupportsInt, SupportsFloat, SupportsComplex, SupportsIndex):
...
def repeat(count: SupportsIndex, action: () -> none) -> none:
for _ in range(count.__index__()):
action()
def magnitude(x: SupportsAbs[float]) -> float:
return abs(x)
def rounded(x: SupportsRound[int]) -> int:
return round(x)
No implicit cross-type numeric behavior¶
Numeric equality and ordering are type-directed. Cross-type numeric equality,
cross-type hashing, and cross-type ordering exist only where explicitly defined.
complex is not orderable; bool is, the ordinary way — false < true.
Bitwise operators are integer-like operations, not general numeric
operations, and are not provided by bool, float, or complex.