arxjit.lowering
arxjit.lowering
Source: packages/arxjit/src/arxjit/lowering.py
title: Python AST to ASTx lowering for arxjit.
summary: >-
Fourth stage of the arxjit pipeline: turn the ast node of a validated
function, plus the Signature reconciliation settled on, into the astx module
IRx compiles. Dispatch is by node type via plum, matching the visitor
convention used across the Arx packages. What is lowered so far is the
function shell — the prototype and its typed arguments — and straight-line
expressions: literals, parameter reads, arithmetic and unary operators, and
single comparisons. Boolean operators and chained comparisons fail closed
until control-flow lowering can preserve their short-circuit behavior. Where
an expression is not lowered at a type its context declares, the type is
inferred from the expression itself, over a scope holding the parameters.
Local assignments and control flow follow in later stages, and until then any
other construct fails closed with LoweringError. The decorator does not call
this stage yet, so nothing here changes what @jit does; that wiring lands
once the lowerer covers the whole validated subset.Functions
location(extracted: ExtractedSource, node: ast.AST) -> astx.SourceLocation
title: Convert an ast node's position into an astx source location.
summary: >-
Reuses the diagnostic builder so astx nodes carry exactly the positions
arxjit reports in its own diagnostics, one-based character columns
included, rather than raw byte offsets. A node without a position, which
only the synthesized ones have, maps to astx's own no-location value.
parameters:
extracted:
type: ExtractedSource
node:
type: ast.AST
returns:
type: astx.SourceLocationscalar(sig_type: SigType) -> Scalar
title: Look up everything needed to lower values at a signature type.
parameters:
sig_type:
type: SigType
returns:
type: Scalar
raises:
LoweringError: If the type names an astx class this stage does not map.astx_type(sig_type: SigType) -> AnyType
title: Instantiate the astx type a signature type lowers to.
parameters:
sig_type:
type: SigType
returns:
type: AnyType
raises:
LoweringError: If the type names an astx class this stage does not map.function_name(python_name: str) -> str
title: Return the astx function name a Python function is emitted under.
summary: >-
Usually the Python name unchanged, so IR dumps and compiled symbols stay
recognisable. A name IRx reserves for the program entry point is prefixed
instead: a decorated function called main is an ordinary compiled
function, but emitting it under that name would subject it to IRx's
entry-point rules, which demand no parameters and an Int32 return.
parameters:
python_name:
type: str
returns:
type: strrepresentable(value: float, single: bool) -> bool
title: Report whether a float value survives the target's precision.
summary: |-
Python floats are doubles, so only a single-precision target can
overflow. Packing and unpacking is the exact test. Loss of precision,
including underflow to zero, is not overflow and is accepted, because
narrowing a float always loses precision and rejecting that would rule
out most decimals.
How struct reports an overflow is not portable: the same value packs to
an infinity on some builds and raises OverflowError on others, and this
differs between platforms at one CPython version rather than only
between versions. Both are the same answer, so both are handled here;
letting the exception escape would also turn a rejectable literal into a
raw stdlib error rather than a diagnostic.
parameters:
value:
type: float
single:
type: bool
returns:
type: boollower(extracted: ExtractedSource, signature: Signature) -> astx.Module
title: Lower a validated function into a single-function astx module.
summary: >-
Takes the function ast from arxjit.source and the Signature from
arxjit.reconcile, and returns the astx module IRx compiles. The module
keeps the Python function's name so a compiled artifact is identifiable,
and holds exactly one function definition: arxjit compiles one decorated
function at a time. The definition itself may be emitted under a
different name; see function_name. Both inputs are expected to have
passed their own stage, so anything this stage cannot map is a
LoweringError rather than a user-facing rejection.
parameters:
extracted:
type: ExtractedSource
description: A function that has already passed validation.
signature:
type: Signature
description: The signature resolve_signature settled on.
returns:
type: astx.Module
raises:
LoweringError: If any part of the function has no astx mapping yet.Classes
Scalar(NamedTuple)
title: Everything this stage needs to lower a value at one scalar type.
summary: >-
Held together in one row rather than in parallel tables so the parts
cannot drift: a type with no literal class, or an integer type with no
range to check against, is not expressible here.
attributes:
type_:
type: type[AnyType]
description: The astx type class, used for arguments and returns.
literal:
type: type[astx.Literal]
description: The astx literal class values are built with.
kind:
type: str
description: Which Python literals belong here; bool, int, or float.
bounds:
type: tuple[int, int] | None
description: The representable integer range, for integer types.
single:
type: bool
description: Whether the type has single rather than double precision.Lowerer
title: Build the astx nodes for one validated function.
summary: >-
Dispatches by node type via plum: each lowerable construct has its own
overload, and the ast.AST overload is the fail-closed default. Failing
closed matters more here than in validation, because this stage runs on a
function already accepted: a node with no overload means the subset and
the lowerer disagree, which must surface rather than silently produce a
module missing part of the function.
attributes:
extracted:
description: The extracted source being lowered.
signature:
description: The signature reconciliation settled on.
scope:
type: dict[str, SigType]
description: The type of every name a lowered expression may read.Methods
reject(self, node: ast.AST, message: str) -> LoweringError
title: Build a LoweringError located at an ast node.
summary: >-
Returned rather than raised so callers raise at the point of failure
and the traceback names the overload that could not proceed.
parameters:
node:
type: ast.AST
message:
type: str
returns:
type: LoweringErrorstatement(self, node: ast.AST) -> astx.AST | None
title: Reject any statement with no overload (fail closed).
parameters:
node:
type: ast.AST
returns:
type: astx.AST | None
raises:
LoweringError: Always.statement(self, node: ast.Pass) -> astx.AST | None
title: Lower a pass statement to nothing.
summary: >-
It has no effect to compile, so it contributes no node rather than an
empty one.
parameters:
node:
type: ast.Pass
returns:
type: astx.AST | Nonestatement(self, node: ast.Expr) -> astx.AST | None
title: Lower a docstring or no-op string statement to nothing.
summary: >-
Validation admits a bare string statement and nothing else in this
position, so the string is discarded and anything else is a
disagreement between the two stages.
parameters:
node:
type: ast.Expr
returns:
type: astx.AST | Nonestatement(self, node: ast.Return) -> astx.AST | None
title: Lower a return statement.
summary: >-
The value is lowered against the signature's return type, which is
the type the returned expression is required to have. A bare return
leaves a function with a declared return type without a value, which
no signature this stage can be given describes, so it is rejected
rather than lowered to a return of nothing.
parameters:
node:
type: ast.Return
returns:
type: astx.AST | None
raises:
LoweringError: If the return has no value.expression(self, node: ast.AST, expected: SigType) -> astx.DataType
title: Reject any expression with no overload (fail closed).
parameters:
node:
type: ast.AST
expected:
type: SigType
returns:
type: astx.DataType
raises:
LoweringError: Always.expression(self, node: ast.Constant, expected: SigType) -> astx.DataType
title: Lower an int, float, or bool literal at its expected type.
summary: >-
The literal is built at the width its context declares rather than at
Python's own, because IRx only inserts safe widening conversions: an
Int64 literal in an i32 function is rejected outright, not narrowed.
The value still has to belong to that type, so a literal of the wrong
kind, or one outside the type's range, is refused here instead of
becoming an astx node that misstates its own value.
parameters:
node:
type: ast.Constant
expected:
type: SigType
returns:
type: astx.DataType
raises:
LoweringError: If the literal's kind or value does not fit.literal(self, node: ast.expr, value: object, expected: SigType) -> astx.DataType
title: Build an astx literal for a value at its expected type.
summary: >-
Takes the value separately from the node it is located at, so that a
negated constant can be folded through here as one literal rather
than lowered as an operator applied to its magnitude. That is not
only a convenience: it is what lets the exact minimum of a signed
type through, since its magnitude is one larger than the maximum.
parameters:
node:
type: ast.expr
value:
type: object
expected:
type: SigType
returns:
type: astx.DataType
raises:
LoweringError: If the value's kind or magnitude does not fit.expression(self, node: ast.Name, expected: SigType) -> astx.DataType
title: Lower a variable read.
summary: >-
Only the parameters are in scope at this stage, and their types are
already declared on the prototype, so the reference carries no type
of its own and IRx resolves it from the declaration. The expected
type is therefore not applied here: unlike a literal, a variable has
the type it was given, and reconciling it with its context is IRx's
to do.
parameters:
node:
type: ast.Name
expected:
type: SigType
returns:
type: astx.DataTypeexpression(self, node: ast.BinOp, expected: SigType) -> astx.DataType
title: Lower an arithmetic binary operation.
summary: >-
Both operands are lowered at the expected type, so a literal in
either position takes the width of its context rather than Python's.
The result type is IRx's to compute from the operands.
parameters:
node:
type: ast.BinOp
expected:
type: SigType
returns:
type: astx.DataType
raises:
LoweringError: If the operator has no astx equivalent.expression(self, node: ast.UnaryOp, expected: SigType) -> astx.DataType
title: Lower a unary operation.
summary: >-
A unary plus is the identity and lowers to its operand alone. A
negated literal is folded into one negative literal, which is the
only form a negative constant takes in Python and the only negation
that can be lowered at all: IRx implements no unary minus, so
negating anything else is refused rather than emitted as a node
codegen would later reject.
parameters:
node:
type: ast.UnaryOp
expected:
type: SigType
returns:
type: astx.DataType
raises:
LoweringError: If the operator has no astx equivalent.expression(self, node: ast.Compare, expected: SigType) -> astx.DataType
title: Lower a single comparison.
summary: >-
The operands are lowered at one type wide enough for all of them
rather than at the expected type, which is the type of the comparison
itself and never the type being compared: at a condition the expected
type is bool, and lowering ``a < 3`` there would ask for 3 as a bool.
Chained comparisons are rejected until this stage can represent their
short-circuit control flow and evaluate each middle operand once.
parameters:
node:
type: ast.Compare
expected:
type: SigType
description: Unused; a comparison is a bool whatever its context.
returns:
type: astx.DataType
raises:
LoweringError: >-
If the comparison is chained or its operator has no IRx equivalent.expression(self, node: ast.BoolOp, expected: SigType) -> astx.DataType
title: Reject and/or until short-circuit control flow is lowerable.
parameters:
node:
type: ast.BoolOp
expected:
type: SigType
description: Unused; the result is logical whatever its context.
returns:
type: astx.DataType
raises:
LoweringError: Always.binary(self, node: ast.expr, op_code: str, lhs: astx.DataType, rhs: astx.DataType) -> astx.BinaryOp
title: Build a binary node, refusing operands astx cannot combine.
summary: >-
astx requires both operands of a binary operator to carry a DataType,
and its UnaryOp carries the generic ExprType instead, so ``not a`` in
either position raises a bare Exception out of astx's constructor
with no source location on it. Checked here so the user gets a
located diagnostic naming the construct, the way every other
unlowerable expression is reported. The real fix belongs upstream in
astx, where giving UnaryOp the type of its operand would make these
compose.
parameters:
node:
type: ast.expr
op_code:
type: str
lhs:
type: astx.DataType
rhs:
type: astx.DataType
returns:
type: astx.BinaryOp
raises:
LoweringError: If either operand carries no astx DataType.promote(self, left: SigType, right: SigType) -> SigType
title: Pick the type that can represent both of two operand types.
summary: >-
Promotion is pairwise because a single ordering cannot describe the
Int64/Float32 pair: IRx promotes it to Float64, while Int32/Float32
remains Float32.
parameters:
left:
type: SigType
right:
type: SigType
returns:
type: SigTypeinfer(self, node: ast.AST) -> SigType
title: Refuse to infer a type for a node with no overload.
summary: >-
Fails closed for the same reason the lowering dispatch does: this
runs on a validated function, so a node reaching here means inference
and the subset disagree, which must surface rather than resolve to
some default type the expression does not have.
parameters:
node:
type: ast.AST
returns:
type: SigType
raises:
LoweringError: Always.infer(self, node: ast.Constant) -> SigType
title: Infer the type of a literal from its Python kind.
summary: >-
The widest type of each kind, because a literal standing on its own
has only Python's own notion of its type to go on: Python integers
are unbounded and its floats are doubles. A narrower type is still
reached wherever the context declares one, which is what the expected
type passed to lowering is for.
parameters:
node:
type: ast.Constant
returns:
type: SigType
raises:
LoweringError: If the literal is of no kind the subset admits.infer(self, node: ast.Name) -> SigType
title: Infer the type of a name from the scope it was declared in.
parameters:
node:
type: ast.Name
returns:
type: SigType
raises:
LoweringError: If the name is not in scope.infer(self, node: ast.BinOp) -> SigType
title: Infer an arithmetic result from its operator and operands.
summary: >-
True division produces a float even when both operands are integers.
Other arithmetic operators use the same pairwise numeric promotion
policy as IRx.
parameters:
node:
type: ast.BinOp
returns:
type: SigTypeinfer(self, node: ast.UnaryOp) -> SigType
title: Infer the type of a unary operation.
summary: >-
Only ``not`` changes the type of what it is applied to; the other
operators the subset admits leave it as it was.
parameters:
node:
type: ast.UnaryOp
returns:
type: SigTypeinfer(self, node: ast.Compare) -> SigType
title: Infer the type of a comparison.
parameters:
node:
type: ast.Compare
returns:
type: SigTypeinfer(self, node: ast.BoolOp) -> SigType
title: Infer the type of an and/or expression.
summary: >-
Logical once lowered, whatever its operands were, which is what makes
it usable as a condition.
parameters:
node:
type: ast.BoolOp
returns:
type: SigTypeliteral_value(self, node: ast.expr, value: object, expected: SigType, target: Scalar) -> bool | int | float
title: Check a literal against its expected type and convert it.
summary: >-
bool is checked before int because it is a subclass of one: without
that order True would satisfy an integer context. An integer in a
float context is converted, which is the widening Python itself
performs; the reverse is not, because a float has no integer value to
preserve. The value is passed in rather than read off the node so a
negated constant is checked as the negative number it is, which is
what admits the exact minimum of a signed type: its magnitude alone
is one larger than that type's maximum.
parameters:
node:
type: ast.expr
value:
type: object
expected:
type: SigType
target:
type: Scalar
returns:
type: bool | int | float
raises:
LoweringError: If the literal's kind or value does not fit.in_range(self, node: ast.expr, value: int, expected: SigType, target: Scalar) -> int
title: Check an integer literal fits the integer type it lowers into.
summary: >-
Python integers are unbounded, so a value has to be checked rather
than assumed to fit: without this one too large to represent would
still be labelled Int64 and misstate its own value.
parameters:
node:
type: ast.expr
value:
type: int
expected:
type: SigType
target:
type: Scalar
returns:
type: int
raises:
LoweringError: If the value is outside the type's range.as_float(self, node: ast.expr, value: int | float, expected: SigType, target: Scalar) -> float
title: Convert a numeric literal to the float type it lowers into.
summary: >-
Converting an integer can overflow when it has more magnitude than a
double holds, and a double can exceed what a single holds, so both
steps are checked rather than left to produce an infinity.
parameters:
node:
type: ast.expr
value:
type: int | float
expected:
type: SigType
target:
type: Scalar
returns:
type: float
raises:
LoweringError: If the value is outside the type's range.arguments(self) -> astx.Arguments
title: Build the typed argument list from the signature.
summary: >-
The names come from the definition and the types from the signature,
which is what makes an explicit signature= able to decide types
without the function having to annotate. Validation rejects every
shape refused here first, but lower is public and the astx argument
list cannot express any of them, so each is refused rather than
quietly dropped: a variadic or keyword-only parameter would vanish
from the prototype, and a default would become a required argument.
returns:
type: astx.Arguments
raises:
LoweringError: If the argument shape or count cannot be lowered.check_shape(self, args: ast.arguments) -> None
title: Reject an argument shape astx.Arguments cannot express.
summary: >-
Checked before the count, because counting the positional parameters
of a function that also takes *args would report a number no caller
could act on. Mirrors the shape check reconciliation applies, so the
two stages refuse the same definitions.
parameters:
args:
type: ast.arguments
raises:
LoweringError: >-
If any parameter is variadic, keyword-only, or has a default.body(self) -> astx.Block
title: Lower the function body into an astx block.
summary: >-
Statements that compile to nothing, a docstring or a pass, are
dropped rather than represented, so the block holds only what IRx has
to translate.
returns:
type: astx.Block