This reference covers the supported API for packages and applications that extend Axiom. For a guided introduction, see Extending Axiom. For the design behind the API, see RFC 0001: Typesafe Axiom.
- Extension entry points
- Dialect composition
- Sources and source compilers
- Predicates and implication
- Types and shapes
- Literal registration
- Operator rules
- Diagnostics
- Compilation analysis
- Execution observation
- Supported boundary
Every plugin contributes through one subclass of Superscript\Axiom\Extension. All hooks default to an empty collection, so override only what the package owns.
abstract class Extension
{
public function identifier(): string;
/** @return list<BinaryOperatorRule> */
public function operators(): array;
/** @return list<UnaryOperatorRule> */
public function unaryOperators(): array;
/** @return array<class-string, callable(object): Type> */
public function literals(): array;
/**
* @return array<
* class-string<Source>,
* callable(Source, SourceCompilation): CompiledSource
* >
*/
public function sourceCompilers(): array;
}| Hook | Contribution | Ownership rule |
|---|---|---|
operators() |
Binary operator rules | A rule owns exactly one symbol. Multiple successful rules are ambiguous. |
unaryOperators() |
Unary operator rules | Same semantics; absence propagates before a unary rule is invoked. |
literals() |
Object class to Type factory |
Duplicate class keys are refused. Avoid overlapping parent/subclass registrations because lookup uses instanceof. |
sourceCompilers() |
Exact Source class to compiler callable |
Ownership is exact-class. Duplicate keys and attempts to claim a core source are refused. |
identifier() defaults to the concrete extension class. Override it with a stable package-level identity when class names may change; compilation analysis attributes every contributed source compiler and operator selection to it. Empty identities are refused.
The extension object is the dependency-injection boundary. Put live services in its constructor and capture them in compiler or operator closures. Keep Source objects as persistable data.
Superscript\Axiom\Dialect is the compile-time collection of core and plugin contributions.
$dialect = Dialect::core()->with(
new MoneyExtension($currencies),
new LookupExtension($repository),
);
$expression = new Expression($source, dialect: $dialect);| Method | Result |
|---|---|
Dialect::core(): Dialect |
Core operators, literal inference, and source compilers. Start here for normal programs. |
$dialect->with(Extension ...$extensions): Dialect |
A new dialect containing the existing and contributed rules. The original is unchanged. |
$dialect->operators(): BinaryOperatorResolver |
Compiler-facing binary resolver. Usually accessed through SourceCompilation::infix(). |
$dialect->unaryOperators(): UnaryOperatorResolver |
Compiler-facing unary resolver. Usually accessed through SourceCompilation::prefix(). |
$dialect->literals(): LiteralTypeRegistry |
The composed object-literal registry. |
$dialect->sourceCompilers(): array |
The composed exact-class compiler map. |
A dialect indexes its rules once. operators(), unaryOperators(), and literals() build on first call and hand out the same instance afterwards; a dialect derived with with() indexes its own. Ask a dialect what it supports as often as you like.
Both resolvers answer two questions. resolve() answers "may these operand types use this symbol?"; symbols() answers "which symbols exist at all?".
$dialect = Dialect::core();
$dialect->operators()->symbols();
// ['!=', '!==', '&&', '*', '+', '-', '/', '<', '<=', '=', '==', '===', '>', '>=', '??', 'has', 'in', 'intersects', 'xor', '||']
$dialect->unaryOperators()->symbols();
// ['!', '-', 'not']
$dialect->operators()->extensions()['=='];
// ['axiom.core'] — and also your identifier once your extension contributes a '==' rowA caller that offers a choice of operators should read symbols() rather than propose a list of its own and filter it through resolve(): a proposed list can only ever shrink, so operators the dialect has and the caller forgot stay invisible, and every operator an extension adds needs the caller changed before anyone can pick it.
Both methods are sorted, not in registration order — composition carries no precedence, so an order derived from it would be an accident to depend on. extensions() returns the same keys in the same order, mapping each symbol to the distinct extension identifiers that claim it (several when extensions contribute rules for one symbol over different operand types). A rule registered without provenance — a resolver constructed directly rather than through a dialect — reports unattributed.
Composition has no precedence. Fixed rows that can admit a common operand type are refused when the dialect is constructed. Any remaining case in which multiple computed rules resolve is refused during compilation. Extension order never chooses an evaluation.
Structural null comparison belongs to infix-expression typing, outside that overload composition. optional === null elaborates directly to observing the option constructor; it never asks what equality means for the payload, so an extension equality rule may own opaque present values but does not compete for this question. Option<Unknown> remains answerable because its option constructor is known, while bare Unknown remains inert because no outer constructor is statically visible. A known total value is disjoint from absence: the strict core elaborates equality to constant-false and inequality to constant-true. That theorem is supplied as the fallback of overload resolution, so a compatibility dialect may explicitly retain a published total/null reading; a fallback never hides two competing claimants.
Superscript\Axiom\Source is the marker interface for a node in a stored program description.
final readonly class ProductSource implements Source
{
public function __construct(
public Source $left,
public Source $right,
) {}
}A source should contain only the data needed to describe the operation. In particular:
- compile every semantic child through
child(),children(),scope(),reference(), orsymbol(); this compiler descent supplies parameter and definition-cycle analysis, so property visibility has no structural meaning; - use
reference()for a rooted reference owned by the source compiler; resolution records the dependency whether theReferencePathwas persisted directly or derived by the compiler; - do not store repositories, HTTP clients, filesystems, containers, or other live services;
- persist the source tree, reconstruct the extension with its services, and then compile;
- optionally implement
Describable::describe(): stringto provide a human-readable representation of the source.
Extension::sourceCompilers() maps an exact source class to a callable:
public function sourceCompilers(): array
{
return [
ProductSource::class => $this->compileProduct(...),
];
}
private function compileProduct(
ProductSource $source,
SourceCompilation $compilation,
): CompiledSource {
// ...
}The map key is the ownership declaration. A compiler registered for a parent class does not compile subclasses. The callable is invoked once per source node during compilation, not once per program invocation or filter.
Nested compilation failures leave the callable through Axiom's private control channel and return from Expression::compile() as the original TypeMismatch. Plugin compilers therefore use straight-line PHP and return CompiledSource, not Result.
The compiler capability passed to every source compiler.
| Method | Meaning |
|---|---|
child(Source $source, ?string $role = null): CompiledSource |
Compile one persisted child in the current dialect, definitions, and type environment. Give structural children a stable role for analysis output. |
children(array $sources): CompiledSources |
Compile named or positional children in array order. Use when no per-child work is needed before composition. |
combine(array $sources): CompiledSources |
Combine CompiledSource values already compiled or certified individually. |
infix(Type $left, string $operator, Type $right): BoundOperation |
Resolve one binary operation from the composed dialect at compile time. Operand types come from typeOf(), which absorbs a failed child, so both operands are certified by construction. |
prefix(string $operator, Type $operand): BoundOperation |
Resolve one unary operation from the composed dialect at compile time. Its operand type comes from typeOf(), as infix()'s does. |
reference(ReferencePath $reference): CompiledSource |
Compile a persisted rooted reference with normal declaration, definition, structural projection, and memoization semantics. |
member(CompiledSource $object, string $property): CompiledSource |
Project one certified record or declared opaque member from an already-compiled source. |
symbol(SymbolSource $symbol): CompiledSource |
Deprecated compatibility adapter; persist a ReferencePath child and call reference() in new source kinds. |
scope(ScopedExpression $expression, array $parameterTypes, ?string $role = null): CompiledScopedExpression |
Compile a lexically scoped body once through the current dialect. Parameter names must exactly match those persisted on the ScopedExpression. They shadow equal outer names; every other symbol resolves through the enclosing inputs and definitions. |
typeOfValue(mixed $value): Type |
Infer an embedded value literal-first. Object values use the dialect's literal registry. |
constant(Type $returns, mixed $value): CompiledSource |
Build a total constant evaluation. null represents absence. |
produces(Type $returns, callable $evaluate): CompiledSource |
Build a source without compiled children, commonly around an injected service. |
custom(Type $returns, callable $evaluate): CompiledSource |
Advanced lazy/control-flow evaluation. The callable may accept SourceEvaluation. |
within(string $message, callable $compile): mixed |
Add a source-specific parent message around a nested compilation refusal. |
| `reject(TypeMismatch | string $mismatch): never` |
overlaps(Type $left, Type $right): Result<bool, TypeMismatch> |
Could a value inhabit both types? Both types come from typeOf(), which absorbs a failed child before answering. |
typeOf(CompiledSource $child): Type |
The certified return type of a compiled child. Absorbs when the child failed to compile — reading CompiledSource::$returns on one throws, so this is how a compiler asks for a child's type. |
shapeOf(CompiledSource $child): Shape |
The structural projection of a compiled child, for certifying a field or member against it. Absorbs when the child failed to compile. |
absorb(): never |
Give up on this source making no refusal; it compiles to a failed source. The judgments above call it for you; call it directly only for a judgment of your own about a child that failed(). |
child(), reference(), member(), symbol(), typeOfValue(), infix(), and prefix() automatically abort the current source compiler when their underlying judgment fails. Do not catch the internal exception.
A persistable source body together with the root symbol names its owner binds:
new ScopedExpression(
parameters: ['candidate'],
body: new InfixExpression(
new SymbolSource('candidate'),
'>',
new SymbolSource('threshold'),
),
);It is not itself a Source: without parameter types it has no type or evaluation. The owning source compiler supplies those types to SourceCompilation::scope(). The compiler descends the body in that nested scope, excludes locally resolved parameters from its recorded references, and retains every genuinely free symbol. In the example, candidate is local and threshold is captured from the enclosing expression. A parameter also shadows an equally named enclosing definition inside the scoped body; that definition's own dependencies remain global and cannot be captured by the local scope.
A compiled source is in one of two states.
A certified source couples a certified return type to its evaluation. A failed source is one whose compilation was refused, with that refusal recorded as a diagnostic; it carries no type and no evaluation, because there is no value for a type to be about. $returns refuses on one, failed() is the question to ask instead, and the capabilities absorb it.
$compiled->failed(); // bool — which state is this in?
$compiled->returns; // Type on a certified source; throws LogicException on a failed oneOnly Expression::diagnose() produces a failed source. Expression::compile() stops at the first refusal, so a compiler it calls is only ever handed children that compiled. Failure is a state, not a type: there is no type standing in for one, so nothing is handed out and nothing can be wrapped and claimed back. Ask failed(), or take the type through SourceCompilation::typeOf(), which absorbs.
| Method | Absence behavior | Callback input |
|---|---|---|
failed(): bool |
True when this source did not compile. It has no return type to read; judge nothing about such a child, and judge through SourceCompilation, which absorbs for you, or call SourceCompilation::absorb(). |
None; this is a compile-time question. |
expectPresent(Type $expected): CompiledSource |
Checks the present member of an optional type; absence remains allowed and propagates later. | None; this is a compile-time certification. |
mapPresent(Type $returns, callable $evaluate): CompiledSource |
An absent input stays absent and the callback is not invoked. If this source is optional, the result type is automatically Option<$returns>. |
The present value. |
mapIncludingAbsent(Type $returns, callable $evaluate): CompiledSource |
The callback is always invoked. | Present value or null. |
apply(BoundOperation $operation, ?Type $returns = null): CompiledSource |
Same as mapPresent(). |
The present value is passed to the unary operation. |
CompiledSource::constant(Type $returns, mixed $value): CompiledSource |
null is absence. |
No callback; low-level equivalent of SourceCompilation::constant(). |
CompiledSource::custom(Type $returns, callable $evaluate): CompiledSource |
The callback decides. | Receives SourceEvaluation; low-level equivalent of SourceCompilation::custom(). |
For mapPresent(), $returns describes the callback's successful present value. Optionality is derived from the input and never needs to be reconstructed by the plugin. For the other methods, $returns describes the whole result. In every case it is a type claim, not a runtime conversion or assertion. Use an honest type and test the callback over representative values.
Prefer construction through SourceCompilation::constant(), produces(), or custom() so compiler code consistently uses its supplied capability. CompiledSource's constructor and node() are internal and are not plugin APIs.
An ordered collection of named or positional compiled children.
| Method | Behavior |
|---|---|
mapPresent(Type $returns, callable $evaluate): CompiledSource |
Evaluates left-to-right. The first absence short-circuits later children. Invokes the callback only when every child is present; if any child is optional, the result type is automatically Option<$returns>. |
mapIncludingAbsent(Type $returns, callable $evaluate): CompiledSource |
Evaluates every child left-to-right and passes each absence as null. |
applyIncludingAbsent(BoundOperation $operation): CompiledSource |
Evaluates every operand, including absence, and invokes the operation bound against those operand types. |
String array keys become named callback arguments, so keep keys aligned with parameter names:
return $compilation->combine([
'left' => $left,
'right' => $right,
])->mapPresent(
new NumberType(),
fn (int|float $left, int|float $right) => $left * $right,
);Use numeric keys for ordinary positional arguments.
The certified result of SourceCompilation::scope(). $returns is the body's inferred type, and expectPresent(Type $expected) checks its present member like the equivalent method on CompiledSource.
Source compilers do not invoke one directly. Pass it to SourceEvaluation::invoke() from a custom() evaluation. The binding keys must exactly match the ScopedExpression's parameters; key order carries no meaning. Each invocation admits the local access paths the body reads through the parameter types supplied during compilation, using the enclosing expression's Boundary policy. Unread record properties are stripped, omitted optional properties retain their ordinary option semantics, and invalid supplied values propagate as boundary failures. A missing required local path yields absence for that invocation, leaving the owning source to decide what absence means.
Each invocation adds its exact local bindings to the current runtime. Free symbols retain their lexical meaning, definitions stay memoized across repeated invocation, expected failures propagate into the enclosing program, and its observer receives the nested source events. Opaque scope identities—not names—select local bindings, so a local parameter cannot accidentally rebind an outer definition compiled against an equal name.
The result of SourceCompilation::infix() or prefix():
$operation->returns; // Type selected during compilation
$operation($left, $right); // mixed; ordinary callable syntaxThe operation is resolved once against the supplied operand types. Invoking it performs no routing or value-directed overload selection. A plain successful value is returned directly. An expected Err from the rule becomes the enclosing source evaluation's Err automatically; an uncaught exception remains a defect and propagates.
Operands are always forwarded positionally. Names used to organize compiled children belong to the source compiler and are discarded before the dialect-owned evaluation closure is invoked.
The runtime values passed to the operation must inhabit the operand types used to bind it. A source compiler that supplies values itself owns that admission guarantee.
Available only inside SourceCompilation::custom():
| Method | Meaning |
|---|---|
value(CompiledSource $source): mixed |
Evaluate an already-compiled child in the current invocation. Returns its value or null for absence; propagates its expected failure. |
invoke(CompiledScopedExpression $expression, array $bindings): mixed |
Admit and evaluate a lexically scoped compiled body with exact local bindings. Returns its value or null for body absence or a missing required local path; propagates invalid supplied values and other expected failures. |
annotate(string $key, mixed $value): void |
Attach domain-specific metadata to the current source's observation node. No-op when the invocation has no observer. |
Use custom() only when ordinary mapping cannot express the source, such as lazy fallback, conditional child evaluation, or source-specific annotations. It is not a way to recover Runtime or perform dynamic compilation.
Do not broadly catch RuntimeException around value(). Expected child failures use a private exception channel to leave the callback and become the enclosing program's Err; swallowing that channel changes program semantics. Catch only the concrete domain exceptions your callback owns.
All source evaluation callbacks follow one convention:
| Callback outcome | Program outcome |
|---|---|
| Plain non-null value | Ok(Some(value)) |
null |
Ok(None()) — structural absence |
Ok(value) |
Ok(Some(value)); Ok(null) is absence |
Err(Throwable) |
Expected value-dependent evaluation failure |
| Thrown exception | Plugin defect; it propagates and is observable as Threw |
Expected runtime failures include a missing remote record or a value-dependent domain rejection that static types cannot rule out. Misconfigured services, impossible callback inputs, and return values that violate the declared type are defects, not normal Err results.
Predicate::fromSource() projects a persisted expression into Axiom's small propositional vocabulary:
| Source shape | Predicate |
|---|---|
left && right |
AllOf(left, right) |
| `left | |
| Any other core or host Source | Opaque Atom(source) |
Repeated conjunctions and disjunctions flatten, duplicate members disappear, and member order does not affect equality. Atom equality uses exact persisted source state, so independently loaded copies match without PHP's loose scalar comparison. A host Source remains one atom even when it contains child Sources; projection never reflectively interprets extension internals.
use Superscript\Axiom\Predicates\Predicate;
use Superscript\Axiom\Predicates\PredicateRelations;
$gate = Predicate::fromSource($gateSource);
$visibleWhen = Predicate::fromSource($visibilitySource);
if (PredicateRelations::implies($gate, $visibleWhen)) {
// Every path represented by the gate proves the visibility predicate.
}The relation proves identity and the structural laws of conjunction and disjunction. It deliberately does not evaluate atoms, reason about negation, or solve arbitrary boolean formulae. false therefore means unproved, never disproved. The caller owns the domain conclusion drawn from a proof and must first establish that projected && and || expressions are valid boolean predicates.
When a host can decide some atoms, Predicate::partiallyEvaluate() applies those values through the same boolean structure. It returns true or false when the whole predicate is decided, otherwise it returns the residual Predicate; call toSource() when that residual must return to expression form. The callback returns null for an atom it cannot decide.
Partial evaluation preserves proposition values, not Axiom's ordinary evaluation plan. A compiled infix expression evaluates both operands before applying && or ||, while partial evaluation may let a dominant known atom discard an undecided branch. The host must therefore know that undecided atoms are total, or deliberately accept suppressing a failure or effect that ordinary runtime evaluation would encounter.
toSource() is a semantic projection, not a syntax round trip. Predicate construction flattens connectives and removes duplicate members, and reconstruction emits a left-associated chain. Do not use it where original Source bytes, grouping, or repeated evaluation are observable.
A custom type implements Superscript\Axiom\Types\Type<T>:
interface Type extends Shaped
{
/** @return Result<Option<T>, Throwable> */
public function assert(mixed $value): Result;
/** @return Result<Option<T>, Throwable> */
public function coerce(mixed $value): Result;
public function format(mixed $value): string;
public function shape(): Shape;
}assert()is strict membership and must not convert.coerce()is the lenient boundary conversion.format()renders an admitted value for humans.shape()projects the type into Axiom's sealed structural vocabulary.
Two laws are mandatory:
- every present value produced by
coerce()must passassert(); - every value admitted by the type must have the runtime structure claimed by
shape().
Plugin code can use these as declarations, return types, fields, operands, and shape parameters.
| Type | Constructor / factory | Domain |
|---|---|---|
BooleanType |
new BooleanType() |
bool |
NumberType |
new NumberType() |
`int |
StringType |
new StringType() |
string |
LiteralType |
`new LiteralType(bool | int |
OptionType |
new OptionType(Type $inner) |
null or an inner value |
UnionType |
new UnionType(Type ...$members) |
Any member |
UnionType::join() |
UnionType::join(Type ...$types) |
Canonical least union; no inputs yields NeverType |
ListType |
new ListType(Type $type, ?int $min = null, ?int $max = null) |
Length-bounded list |
DictType |
new DictType(Type $type) |
String-keyed homogeneous map |
RecordType |
new RecordType(array<string, Type> $fields) |
Exact named record |
UnknownType |
new UnknownType() |
Genuinely untyped value; inert at operators and member access |
NeverType |
new NeverType() |
Empty value set; normally derived rather than declared |
OpaqueType is an internal reified stand-in and cannot verify host values. A plugin declaring an opaque domain must implement its own Type with real assert()/coerce() methods and return an OpaqueShape.
Shapes are value-set descriptions, not runtime validators. Custom types project into one of these constructors:
| Shape | Construction | Meaning |
|---|---|---|
BooleanShape |
new BooleanShape() |
Boolean values |
NumberShape |
new NumberShape() |
Numeric values |
StringShape |
new StringShape() |
String values |
LiteralShape |
new LiteralShape($value) |
One scalar value |
OptionShape |
new OptionShape($inner) |
null plus the inner domain; nesting collapses |
UnionShape |
UnionShape::of(...$members) |
Canonical alternatives; construct through of() |
ListShape |
new ListShape($element, $min = 0, $max = null) |
Length-bounded list |
DictShape |
new DictShape($value) |
String-keyed homogeneous map |
RecordShape |
new RecordShape($fields) |
Exact named fields |
OpaqueShape |
new OpaqueShape($identity, $parameters = []) |
Nominal identity with covariant structural parameters |
UnknownShape |
new UnknownShape() |
No static knowledge |
NeverShape |
new NeverShape() |
No possible value |
Every shape implements equals(Shape $other): bool. Do not infer assignability or compatibility from equals(); use TypeRelations.
ShapeDomain::all(Shape $shape, callable $leaf): bool answers whether a rule's leaf predicate supports every reachable value domain in a composite shape. It treats Unknown as unsupported and Never as vacuously supported. This is primarily useful for advanced structural operator rules.
All relation methods return Ok(true) or Err(TypeMismatch), never a boolean false.
| Method | Question |
|---|---|
TypeRelations::isTypeAssignableTo($source, $target) |
Can every source value flow into the target slot? |
TypeRelations::areEquivalent($a, $b) |
Are both types mutually assignable? |
TypeRelations::overlaps($a, $b) |
Could one value inhabit both types? This does not establish operator support. |
TypeRelations::admits($operand, $slot) |
Can this operand type reach this operator slot? Same basis as assignability, but Unknown is explicitly inert. |
TypeRelations::jointlyAdmissible($a, $b) |
Could one operand type reach both slots? Used for fixed-row ambiguity. |
TypeRelations::assignable($sourceShape, $targetShape) |
Shape-level assignability. |
TypeRelations::shapesEquivalent($a, $b) |
Shape-level equivalence. |
TypeRelations::shapesOverlap($a, $b) |
Shape-level overlap. |
TypeRelations::shapesJointlyAdmissible($a, $b) |
Shape-level joint admissibility. |
Helpers:
TypeDescriber::describe(Type $type): string,describeShape(Shape $shape): string, anddescribeClass(class-string<Type> $type): stringare the single diagnostic rendering authority.TypeReifier::reify(Shape $shape): Typeconstructs the canonical core type for a shape. ReifyingOpaqueShapeproduces an internal non-admitting stand-in; retain your domain type when runtime membership matters.
Register an object value class when that object may appear directly in a StaticSource or be passed to SourceCompilation::typeOfValue():
public function literals(): array
{
return [
Money::class => fn (Money $value): Type => new MoneyType($value->getCurrency()->getCurrencyCode()),
];
}The factory is a static typing function, not a conversion. Its returned type must assert the value it receives. Scalars and arrays are typed structurally and never reach this registry. An unregistered object literal is a compile error rather than Unknown.
Lookup uses instanceof. Prefer concrete non-overlapping registrations; a parent mapping and child mapping can both match the same value.
Register binary rules through Extension::operators() and unary rules through Extension::unaryOperators(). Each rule advertises one operator symbol.
Use a fixed row when operand and return types are known at extension composition time:
Operator::infix('+')
->identifiedBy('money.eur.add')
->takes(new MoneyType('EUR'), new MoneyType('EUR'))
->returns(new MoneyType('EUR'))
->evaluatesWith(fn (Money $left, Money $right) => $left->plus($right));
Operator::prefix('negate')
->identifiedBy('money.eur.negate')
->takes(new MoneyType('EUR'))
->returns(new MoneyType('EUR'))
->evaluatesWith(fn (Money $money) => $money->negated());The staged chains are:
Operator::infix(symbol) → identifiedBy(id) → takes(left, right) → returns(type) → evaluatesWith(callable)
Operator::prefix(symbol) → identifiedBy(id) → takes(operand) → returns(type) → evaluatesWith(callable)
identifiedBy() is optional but recommended for public rules: it gives the selected rule a stable semantic identity in compilation analysis. Without it, fixed and computed builders derive a deterministic fallback. The last call returns BinaryOperatorRule or UnaryOperatorRule. A prefix row cannot take an OptionType: unary absence propagates structurally before rule evaluation.
Use a typed computed rule when the concrete Type classes identify ownership but type data determines the verdict or return type:
Operator::infix('+')
->identifiedBy('money.add')
->matching(MoneyType::class, MoneyType::class)
->resolvesWith(function (MoneyType $left, MoneyType $right): OperatorResolution {
if ($left->currency !== $right->currency) {
return Operation::unsupported('Money currencies must match.');
}
return Operation::returns($left)
->evaluatesWith(fn (Money $a, Money $b) => $a->plus($b));
});Unary form:
Operator::prefix(symbol) → matching(TypeClass::class) → resolvesWith(callable)
Non-matching type classes are refused automatically and never invoke the callback. Matching uses instanceof, so subclasses of the named Type class are included; use a final type class or an explicit early refusal when a hierarchy needs narrower ownership.
Operation provides concise constructors:
| Method | Result |
|---|---|
Operation::returns(Type $type)->evaluatesWith(callable $evaluation) |
ResolvedOperation |
Operation::unsupported(string $message, array $causes = []) |
UnsupportedOperation |
Operation::dead(string $message, array $causes = []) |
DeadOperation |
Use the interfaces directly for fully structural judgments, absence-aware binary semantics, or logic that cannot be guarded by concrete Type classes:
interface BinaryOperatorRule
{
public function operator(): string;
public function resolve(Type $left, Type $right): OperatorResolution;
}
interface UnaryOperatorRule
{
public function operator(): string;
public function resolve(Type $operand): OperatorResolution;
}A hand-written rule may also implement IdentifiedOperatorRule::identifier(). Otherwise its concrete implementation class is its analysis identity.
resolve() judges types only. It must not inspect runtime values or route aliases. Register aliases as separate rule instances, each returning its own symbol from operator().
OperatorResolution has three supported variants:
| Variant | Meaning |
|---|---|
ResolvedOperation(Type $returns, Closure $evaluation) |
This rule certifies the operands. The closure is bound into the program. |
UnsupportedOperation(string $message, array $causes = []) |
The rule owns the symbol but rejects these operand types. |
DeadOperation(string $message, array $causes = []) |
The operation is valid in principle but statically constant or meaningless. |
A ResolvedOperation evaluation may return a plain value or a Result. A plain value is wrapped in Ok; a returned Result passes through; a thrown exception propagates. The closure must be total over every runtime value admitted by the operand types, and every successful result must inhabit $returns.
ResolvedOperation::evaluate(mixed ...$operands): Result is the direct evaluation method used by compiler infrastructure and totality tests. Source compilers normally receive its BoundOperation wrapper from SourceCompilation, which removes the Result ceremony and propagates expected failures for them.
TypeMismatch is the public negative type judgment:
new TypeMismatch(
message: 'Money currencies must match.',
causes: [$cause],
dead: false,
);| Member | Meaning |
|---|---|
$message |
Human-readable local verdict. |
$causes |
Nested TypeMismatch causes, preserving context. |
$dead |
The program is well-formed in principle but statically meaningless. Normally set by the compiler when converting DeadOperation. |
$path |
Where the refusal was made, in the same language compilation analysis uses — or null when the verdict is not about a node. See below. |
describe(): string |
Render the complete indented cause tree. Paths are not rendered; read them from $path. |
Use SourceCompilation::reject() for a compiler-owned refusal and within() to add context. Use UnsupportedOperation or DeadOperation at an operator-rule boundary. Do not throw TypeMismatch; it is a value, not an exception. A compiler-owned refusal needs no path: the compiler stamps the failing node's own path on the way out.
(name + 1) * 2 with name declared String fails at the inner +, and says so:
$failure = $expression->compile()->unwrapErr();
$failure->message; // '[+] expects Number and Number; got String and 1.'
$failure->path; // '$.children[0].node'
$failure->causes[0]->path; // null$path is the compilation-analysis path of that node — the same string $analysis->toArray() gives it when the tree compiles — so one addressing scheme serves both channels, and the ancestor chain falls out of the prefixes. The path names the deepest node that refused, since that is the one to point at.
null means the verdict is not about a node, and reads as "not a node's fault" rather than "location unknown". Claims about types rather than nodes keep it: the cause above, String is not assignable to Number., comes from a relation given two types; nothing at that level knows which node produced either, because infix() receives types, not the children they came from. Definition cycles, by contrast, are refused at the symbol read that closes the cycle because cycle analysis now follows the compiler's descent.
Refusals that a compiler wraps with within() carry one path per level, outermost first — a match arm body that will not type gives the match node's path on the wrapper and the arm's path on the cause.
Successful compilation exposes its data-only explanation as Program::$analysis; Expression::analyze() returns the same kind of artifact without requiring the caller to keep the program.
| API | Result |
|---|---|
$analysis->root |
The typed CompilationNode graph, including source classes, source-compiler extension identities, named children, and local operator selections. |
$analysis->operators() |
A flat list of LocatedOperatorSelection values with deterministic source and selection paths. |
$analysis->toArray(bool $revealLiterals = false) |
Versioned serializable representation. Literal values inside inferred types are redacted by default. |
json_encode($analysis) |
The default redacted representation through JsonSerializable. |
An operator selection retains its typed operands and return type plus OperatorRuleProvenance: stable identifier, implementation class, and owning extension. The artifact never contains evaluation closures or collaborators captured by source compilers. Treat it as an explanation and audit format, not as an alternative serialization of the authoring Source tree.
A CompilationNode records which of three states compilation left a source in — CompilationState::Certified, Failed or Abandoned, on $state. A reader of an analysis meets two of them.
A certified node is the ordinary one: it answers $returns and $extension, and renders them.
A failed node is one the compiler gave up on. It never appears in an analysis, because an analysis is built only for a certified program: Program's constructor refuses any tree containing one, reading $containsFailure — the bottom-up bit that answers for a whole subtree — rather than inspecting types. Like an abandoned node it claims neither a type nor an owning compiler.
An abandoned node stands where a child refused and its compiler caught the refusal and compiled without it — the pattern CoerceSourceCompiler uses for a static value the literal registry cannot type. Nothing was compiled there, so the node claims neither a type nor an owning compiler: reading $returns or $extension throws a LogicException, and toArray() renders the position alone:
['path' => '$.children[0].node', 'source' => Sources\StaticSource::class, 'abandoned' => true]It is recorded because paths are positional: $.children[1].node names the second child, and Expression::diagnose() compiles an expression several times. Were a refusing child to record nothing, the sibling after it would take its index in the attempts where it refuses and a different one in the attempts where it is set aside — and one fault would be reported at two paths. An abandoned node is not a failure and never blocks certification; it has no children and no operators, so operators() and the certification walk pass through it unchanged.
Hosts and tracing plugins implement Superscript\Axiom\Execution\Observer:
interface Observer
{
public function observe(Event $event): void;
}
$program($bindings, observer: $observer);Every event exposes a Node with:
$sourceType: the concrete source class;$returns: the source's certified return type.
Event variants:
| Event | Additional data |
|---|---|
Entered |
The node is about to evaluate. |
Exited |
$result, the node's Result<Option<mixed>, Throwable>. |
Annotated |
$key and $value, emitted by core or SourceEvaluation::annotate(). |
Threw |
$exception, for a defect that escaped evaluation. |
Events are ordered and nested. Observation does not change evaluation results. Plugin source compilers can add annotations only from a custom() evaluation; ordinary composed sources receive their node lifecycle automatically.
Plugin code is expected to depend on the APIs documented here. The following classes may be public for compiler implementation or testing but are not extension-facing APIs:
CompiledNode,Runtime, andLocalScope;CompilationAbortedandEvaluationAborted;CoreSourceCompilersand classes underSourceCompilers;- direct construction of
SourceCompilation,CompiledSource,CompiledSources,CompiledScopedExpression,BoundOperation, orSourceEvaluation; BinaryOperatorResolverandUnaryOperatorResolveras runtime services;TypeInferenceandTypeEnvironmentfor implementing source compilers;Analysis\CompilationState, the compiler's record of what became of each source. Failure is one of its cases, not a type: there is no type standing for a node that failed, so there is nothing to be handed, nothing to wrap in a type of your own, and nothing for certification to search a type for. AProgramrefuses to be built from a tree in which anything failed, and your own types — including composites Axiom cannot see inside — are never inspected. See "Report Failures at the Right Boundary" in the extension guide.
Use Extension, Dialect, and the capabilities passed to your compiler instead. That keeps persisted sources serializable, plugin code independent of Axiom's execution representation, and compiled programs free of runtime dispatch.